@f0rbit/ui 0.1.2 → 0.1.6

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.
@@ -1,62 +1,146 @@
1
- import { type JSX, splitProps, createSignal, createContext, useContext, Show, For } from "solid-js";
2
-
3
- type TabsContextValue = {
4
- activeTab: () => string;
5
- setActiveTab: (id: string) => void;
6
- };
7
-
8
- const TabsContext = createContext<TabsContextValue>();
9
-
1
+ import { type JSX, splitProps, onMount, onCleanup, createSignal } from "solid-js";
2
+
3
+ /**
4
+ * A tab container component that manages tab selection and panel visibility.
5
+ *
6
+ * Uses DOM-based discovery via data attributes, making it compatible with
7
+ * Astro's island architecture where each child component hydrates independently.
8
+ *
9
+ * @example Basic usage in SolidJS
10
+ * ```tsx
11
+ * <Tabs defaultValue="tab1">
12
+ * <TabList>
13
+ * <Tab value="tab1">First Tab</Tab>
14
+ * <Tab value="tab2">Second Tab</Tab>
15
+ * </TabList>
16
+ * <TabPanel value="tab1">First panel content</TabPanel>
17
+ * <TabPanel value="tab2">Second panel content</TabPanel>
18
+ * </Tabs>
19
+ * ```
20
+ *
21
+ * @example Usage in Astro MDX (each component needs client:load)
22
+ * ```mdx
23
+ * <Tabs defaultValue="tab1" client:load>
24
+ * <TabList client:load>
25
+ * <Tab value="tab1" client:load>First Tab</Tab>
26
+ * <Tab value="tab2" client:load>Second Tab</Tab>
27
+ * </TabList>
28
+ * <TabPanel value="tab1" client:load>First panel content</TabPanel>
29
+ * <TabPanel value="tab2" client:load>Second panel content</TabPanel>
30
+ * </Tabs>
31
+ * ```
32
+ *
33
+ * @example Creating a wrapper to avoid multiple client:load directives
34
+ * ```tsx
35
+ * // MyTabs.tsx - your custom wrapper
36
+ * import { Tabs, TabList, Tab, TabPanel } from '@f0rbit/ui';
37
+ *
38
+ * export function MyTabs() {
39
+ * return (
40
+ * <Tabs defaultValue="tab1">
41
+ * <TabList>
42
+ * <Tab value="tab1">First</Tab>
43
+ * <Tab value="tab2">Second</Tab>
44
+ * </TabList>
45
+ * <TabPanel value="tab1">Content 1</TabPanel>
46
+ * <TabPanel value="tab2">Content 2</TabPanel>
47
+ * </Tabs>
48
+ * );
49
+ * }
50
+ *
51
+ * // Then in Astro: <MyTabs client:load />
52
+ * ```
53
+ */
10
54
  export interface TabsProps extends JSX.HTMLAttributes<HTMLDivElement> {
55
+ /** The value of the initially selected tab */
11
56
  defaultValue?: string;
12
- value?: string;
13
- onValueChange?: (value: string) => void;
14
57
  children: JSX.Element;
15
58
  }
16
59
 
17
- export interface TabListProps extends JSX.HTMLAttributes<HTMLDivElement> {
18
- children: JSX.Element;
19
- }
60
+ export function Tabs(props: TabsProps) {
61
+ const [local, rest] = splitProps(props, ["defaultValue", "children", "class"]);
62
+ let containerRef: HTMLDivElement | undefined;
63
+ const [activeTab, setActiveTab] = createSignal(local.defaultValue ?? "");
64
+
65
+ const updateTabs = (value: string) => {
66
+ if (!containerRef) return;
67
+
68
+ const tabs = containerRef.querySelectorAll<HTMLButtonElement>("[data-tab-value]");
69
+ tabs.forEach((tab) => {
70
+ const isActive = tab.dataset.tabValue === value;
71
+ tab.setAttribute("aria-selected", String(isActive));
72
+ tab.setAttribute("tabindex", isActive ? "0" : "-1");
73
+ tab.classList.toggle("active", isActive);
74
+ });
75
+
76
+ const panels = containerRef.querySelectorAll<HTMLDivElement>("[data-panel-value]");
77
+ panels.forEach((panel) => {
78
+ const isActive = panel.dataset.panelValue === value;
79
+ panel.hidden = !isActive;
80
+ });
81
+ };
20
82
 
21
- export interface TabProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
22
- value: string;
23
- children: JSX.Element;
24
- }
83
+ const initializeTabs = () => {
84
+ if (!containerRef) return;
25
85
 
26
- export interface TabPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
27
- value: string;
28
- children: JSX.Element;
29
- }
86
+ let value = activeTab();
87
+ if (!value) {
88
+ const firstTab = containerRef.querySelector<HTMLButtonElement>("[data-tab-value]");
89
+ value = firstTab?.dataset.tabValue ?? "";
90
+ if (value) setActiveTab(value);
91
+ }
30
92
 
31
- export function Tabs(props: TabsProps) {
32
- const [local, rest] = splitProps(props, ["defaultValue", "value", "onValueChange", "children", "class"]);
93
+ if (value) updateTabs(value);
94
+ };
33
95
 
34
- const [internalValue, setInternalValue] = createSignal(local.defaultValue ?? "");
96
+ onMount(() => {
97
+ if (!containerRef) return;
35
98
 
36
- const isControlled = () => local.value !== undefined;
37
- const activeTab = () => (isControlled() ? local.value! : internalValue());
99
+ // Use event delegation for clicks - handles dynamically added children
100
+ containerRef.addEventListener("click", (e) => {
101
+ const tab = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-tab-value]");
102
+ if (tab?.dataset.tabValue) {
103
+ setActiveTab(tab.dataset.tabValue);
104
+ updateTabs(tab.dataset.tabValue);
105
+ }
106
+ });
38
107
 
39
- const setActiveTab = (id: string) => {
40
- if (!isControlled()) {
41
- setInternalValue(id);
42
- }
43
- local.onValueChange?.(id);
44
- };
108
+ // Initialize with any children already present
109
+ initializeTabs();
110
+
111
+ // Watch for new children being added (e.g., Astro islands hydrating)
112
+ const observer = new MutationObserver(() => {
113
+ initializeTabs();
114
+ });
115
+
116
+ observer.observe(containerRef, {
117
+ childList: true,
118
+ subtree: true,
119
+ });
120
+
121
+ onCleanup(() => observer.disconnect());
122
+ });
45
123
 
46
124
  const classes = () => `tabs ${local.class ?? ""}`.trim();
47
125
 
48
126
  return (
49
- <TabsContext.Provider value={{ activeTab, setActiveTab }}>
50
- <div class={classes()} {...rest}>
51
- {local.children}
52
- </div>
53
- </TabsContext.Provider>
127
+ <div ref={containerRef} class={classes()} data-default-value={local.defaultValue} {...rest}>
128
+ {local.children}
129
+ </div>
54
130
  );
55
131
  }
56
132
 
133
+ /**
134
+ * Container for Tab buttons. Provides the tablist role for accessibility.
135
+ *
136
+ * In Astro, requires `client:load` directive.
137
+ */
138
+ export interface TabListProps extends JSX.HTMLAttributes<HTMLDivElement> {
139
+ children: JSX.Element;
140
+ }
141
+
57
142
  export function TabList(props: TabListProps) {
58
143
  const [local, rest] = splitProps(props, ["children", "class"]);
59
-
60
144
  const classes = () => `tab-list ${local.class ?? ""}`.trim();
61
145
 
62
146
  return (
@@ -66,35 +150,30 @@ export function TabList(props: TabListProps) {
66
150
  );
67
151
  }
68
152
 
153
+ /**
154
+ * A tab button that switches the active panel when clicked.
155
+ *
156
+ * The `value` prop must match a corresponding `TabPanel` value.
157
+ * In Astro, requires `client:load` directive.
158
+ */
159
+ export interface TabProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
160
+ /** Unique identifier that links this tab to its panel */
161
+ value: string;
162
+ children: JSX.Element;
163
+ }
164
+
69
165
  export function Tab(props: TabProps) {
70
166
  const [local, rest] = splitProps(props, ["value", "children", "class"]);
71
- const ctx = useContext(TabsContext);
72
-
73
- const isActive = () => ctx?.activeTab() === local.value;
74
-
75
- const handleClick = () => {
76
- ctx?.setActiveTab(local.value);
77
- };
78
-
79
- const classes = () => {
80
- const parts = ["tab"];
81
- if (isActive()) {
82
- parts.push("active");
83
- }
84
- if (local.class) {
85
- parts.push(local.class);
86
- }
87
- return parts.join(" ");
88
- };
167
+ const classes = () => `tab ${local.class ?? ""}`.trim();
89
168
 
90
169
  return (
91
170
  <button
92
171
  type="button"
93
172
  role="tab"
94
- aria-selected={isActive()}
95
- tabIndex={isActive() ? 0 : -1}
173
+ aria-selected="false"
174
+ tabIndex={-1}
96
175
  class={classes()}
97
- onClick={handleClick}
176
+ data-tab-value={local.value}
98
177
  {...rest}
99
178
  >
100
179
  {local.children}
@@ -102,19 +181,26 @@ export function Tab(props: TabProps) {
102
181
  );
103
182
  }
104
183
 
184
+ /**
185
+ * Content panel that is shown when its corresponding Tab is active.
186
+ *
187
+ * The `value` prop must match a corresponding `Tab` value.
188
+ * Panels are hidden by default and shown when their tab is selected.
189
+ * In Astro, requires `client:load` directive.
190
+ */
191
+ export interface TabPanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
192
+ /** Unique identifier that links this panel to its tab */
193
+ value: string;
194
+ children: JSX.Element;
195
+ }
196
+
105
197
  export function TabPanel(props: TabPanelProps) {
106
198
  const [local, rest] = splitProps(props, ["value", "children", "class"]);
107
- const ctx = useContext(TabsContext);
108
-
109
- const isActive = () => ctx?.activeTab() === local.value;
110
-
111
199
  const classes = () => `tab-panel ${local.class ?? ""}`.trim();
112
200
 
113
201
  return (
114
- <Show when={isActive()}>
115
- <div class={classes()} role="tabpanel" {...rest}>
116
- {local.children}
117
- </div>
118
- </Show>
202
+ <div class={classes()} role="tabpanel" data-panel-value={local.value} hidden {...rest}>
203
+ {local.children}
204
+ </div>
119
205
  );
120
206
  }
@@ -700,6 +700,10 @@
700
700
  color: var(--fg-muted);
701
701
  }
702
702
 
703
+ .step-body {
704
+ margin-top: var(--space-sm);
705
+ }
706
+
703
707
  /* Tabs */
704
708
  .tabs {
705
709
  display: flex;