@marianmeres/stuic 2.46.0 → 2.48.0

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.
@@ -103,7 +103,7 @@
103
103
  transition-colors duration-100
104
104
 
105
105
  hover:brightness-105 active:brightness-95
106
- data-[disabled=true]:!cursor-not-allowed data-[disabled=true]:!opacity-50 data-[disabled=true]:hover:brightness-100
106
+ data-[disabled=true]:cursor-not-allowed! data-[disabled=true]:opacity-50! data-[disabled=true]:hover:brightness-100
107
107
 
108
108
  bg-neutral-400 dark:bg-neutral-400
109
109
 
@@ -0,0 +1,84 @@
1
+ # TabbedMenu
2
+
3
+ A horizontal tab navigation component built on semantic `ul/li` markup with ARIA accessibility support.
4
+
5
+ ## Usage
6
+
7
+ ```svelte
8
+ <script>
9
+ import { TabbedMenu } from '@marianmeres/stuic';
10
+
11
+ let activeTab = $state('tab1');
12
+
13
+ const items = [
14
+ { id: 'tab1', label: 'First Tab' },
15
+ { id: 'tab2', label: 'Second Tab' },
16
+ { id: 'tab3', label: 'Third Tab', disabled: true },
17
+ ];
18
+ </script>
19
+
20
+ <TabbedMenu {items} bind:value={activeTab} />
21
+ ```
22
+
23
+ ## Props
24
+
25
+ | Prop | Type | Default | Description |
26
+ | --------------------- | --------------------------------- | ----------- | ------------------------------------ |
27
+ | `items` | `TabbedMenuItem[]` | required | Array of tab items |
28
+ | `value` | `string \| number` | `undefined` | Active tab id (bindable) |
29
+ | `disabled` | `boolean` | `false` | Disable all tabs |
30
+ | `onSelect` | `(item: TabbedMenuItem) => void` | `undefined` | Callback when tab is selected |
31
+ | `class` | `string` | `undefined` | Class for the `ul` wrapper |
32
+ | `classItem` | `string` | `undefined` | Class for each `li` element |
33
+ | `classButton` | `string` | `undefined` | Class for tab buttons |
34
+ | `classButtonActive` | `string` | `undefined` | Additional class for active tab |
35
+ | `classButtonDisabled` | `string` | `undefined` | Additional class for disabled tabs |
36
+ | `unstyled` | `boolean` | `false` | Skip default styling |
37
+ | `el` | `HTMLUListElement` | `undefined` | Element reference (bindable) |
38
+
39
+ ## TabbedMenuItem Interface
40
+
41
+ ```typescript
42
+ interface TabbedMenuItem {
43
+ id: string | number;
44
+ label: THC; // Text, HTML, Component, or Snippet
45
+ disabled?: boolean;
46
+ class?: string;
47
+ data?: Record<string, any>;
48
+ onSelect?: () => void | boolean; // Return false to prevent selection
49
+ }
50
+ ```
51
+
52
+ ## CSS Variables
53
+
54
+ ```css
55
+ /* Tab button */
56
+ --color-tabbed-menu-tab-bg
57
+ --color-tabbed-menu-tab-bg-dark
58
+ --color-tabbed-menu-tab-text
59
+ --color-tabbed-menu-tab-text-dark
60
+
61
+ /* Active tab */
62
+ --color-tabbed-menu-tab-active-bg
63
+ --color-tabbed-menu-tab-active-bg-dark
64
+ --color-tabbed-menu-tab-active-text
65
+ --color-tabbed-menu-tab-active-text-dark
66
+
67
+ /* Border */
68
+ --color-tabbed-menu-border
69
+ --color-tabbed-menu-border-dark
70
+
71
+ /* Sizing */
72
+ --tabbed-menu-border-radius: 0.5rem
73
+ --tabbed-menu-padding-x: 1rem
74
+ --tabbed-menu-padding-y: 0.5rem
75
+ --tabbed-menu-gap: 0.25rem
76
+ ```
77
+
78
+ ## Keyboard Navigation
79
+
80
+ - `ArrowRight` / `ArrowDown` - Move to next tab
81
+ - `ArrowLeft` / `ArrowUp` - Move to previous tab
82
+ - `Home` - Move to first tab
83
+ - `End` - Move to last tab
84
+ - `Enter` / `Space` - Select focused tab
@@ -0,0 +1,184 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { THC } from "../Thc/index.js";
4
+ import { twMerge } from "../../utils/tw-merge.js";
5
+ import Thc from "../Thc/Thc.svelte";
6
+
7
+ export interface TabbedMenuItem {
8
+ id: string | number;
9
+ label: THC;
10
+ disabled?: boolean;
11
+ class?: string;
12
+ data?: Record<string, any>;
13
+ onSelect?: () => void | boolean;
14
+ href?: string;
15
+ }
16
+
17
+ export interface Props extends Omit<HTMLAttributes<HTMLUListElement>, "children"> {
18
+ items: TabbedMenuItem[];
19
+ value?: string | number;
20
+ disabled?: boolean;
21
+ onSelect?: (item: TabbedMenuItem) => void;
22
+ //
23
+ class?: string;
24
+ classItem?: string;
25
+ classButton?: string;
26
+ classButtonActive?: string;
27
+ classButtonDisabled?: string;
28
+ //
29
+ unstyled?: boolean;
30
+ //
31
+ el?: HTMLUListElement;
32
+ }
33
+ </script>
34
+
35
+ <script lang="ts">
36
+ import "./index.css";
37
+
38
+ //
39
+ let {
40
+ items,
41
+ value = $bindable(),
42
+ disabled,
43
+ onSelect,
44
+ //
45
+ class: classProp,
46
+ classItem,
47
+ classButton,
48
+ classButtonActive,
49
+ classButtonDisabled,
50
+ //
51
+ unstyled,
52
+ //
53
+ el = $bindable(),
54
+ ...rest
55
+ }: Props = $props();
56
+
57
+ const CLS = `
58
+ stuic-tabbed-menu
59
+ flex flex-row items-end
60
+ gap-1
61
+ list-none m-0 p-0
62
+ `;
63
+
64
+ const CLS_ITEM = `
65
+ min-w-0 flex-1
66
+ `;
67
+
68
+ const CLS_BUTTON = `
69
+ px-4 py-2
70
+ rounded-t-md
71
+ border border-b-0
72
+ border-tabbed-menu-border dark:border-tabbed-menu-border-dark
73
+ bg-tabbed-menu-tab-bg dark:bg-tabbed-menu-tab-bg-dark
74
+ text-tabbed-menu-tab-text dark:text-tabbed-menu-tab-text-dark
75
+ cursor-pointer
76
+ transition-colors duration-150
77
+ hover:brightness-105
78
+ focus-visible:outline-2 focus-visible:outline-offset-2
79
+ truncate w-full
80
+ block
81
+ text-center
82
+ `;
83
+
84
+ const CLS_BUTTON_ACTIVE = `
85
+ bg-tabbed-menu-tab-active-bg dark:bg-tabbed-menu-tab-active-bg-dark
86
+ text-tabbed-menu-tab-active-text dark:text-tabbed-menu-tab-active-text-dark
87
+ font-medium
88
+ `;
89
+
90
+ const CLS_BUTTON_DISABLED = `
91
+ opacity-50 cursor-not-allowed
92
+ pointer-events-none
93
+ `;
94
+
95
+ let buttonEls = $state<Record<string | number, HTMLButtonElement | HTMLAnchorElement>>(
96
+ {}
97
+ );
98
+
99
+ function selectItem(item: TabbedMenuItem) {
100
+ if (item.disabled || disabled) return;
101
+ // item-level handler takes priority
102
+ if (item.onSelect?.() === false) return;
103
+ value = item.id;
104
+ onSelect?.(item);
105
+ }
106
+
107
+ function handleKeydown(e: KeyboardEvent, item: TabbedMenuItem) {
108
+ const enabledItems = items.filter((i) => !i.disabled && !disabled);
109
+ const currentEnabledIndex = enabledItems.findIndex((i) => i.id === item.id);
110
+
111
+ if (["ArrowRight", "ArrowDown"].includes(e.key)) {
112
+ e.preventDefault();
113
+ const nextIndex = (currentEnabledIndex + 1) % enabledItems.length;
114
+ const nextItem = enabledItems[nextIndex];
115
+ buttonEls[nextItem.id]?.focus();
116
+ } else if (["ArrowLeft", "ArrowUp"].includes(e.key)) {
117
+ e.preventDefault();
118
+ const prevIndex =
119
+ (currentEnabledIndex - 1 + enabledItems.length) % enabledItems.length;
120
+ const prevItem = enabledItems[prevIndex];
121
+ buttonEls[prevItem.id]?.focus();
122
+ } else if (["Enter", " "].includes(e.key)) {
123
+ // For anchors, let Enter trigger native navigation (onclick still fires)
124
+ if (e.key === "Enter" && item.href) return;
125
+ e.preventDefault();
126
+ selectItem(item);
127
+ } else if (e.key === "Home") {
128
+ e.preventDefault();
129
+ const firstItem = enabledItems[0];
130
+ if (firstItem) buttonEls[firstItem.id]?.focus();
131
+ } else if (e.key === "End") {
132
+ e.preventDefault();
133
+ const lastItem = enabledItems[enabledItems.length - 1];
134
+ if (lastItem) buttonEls[lastItem.id]?.focus();
135
+ }
136
+ }
137
+
138
+ function getButtonClass(item: TabbedMenuItem): string {
139
+ const isActive = value === item.id;
140
+ const isDisabled = item.disabled || disabled;
141
+
142
+ return twMerge(
143
+ !unstyled && CLS_BUTTON,
144
+ classButton,
145
+ isActive && !unstyled && CLS_BUTTON_ACTIVE,
146
+ isActive && classButtonActive,
147
+ isDisabled && !unstyled && CLS_BUTTON_DISABLED,
148
+ isDisabled && classButtonDisabled,
149
+ item.class
150
+ );
151
+ }
152
+ </script>
153
+
154
+ {#if items.length}
155
+ <ul
156
+ bind:this={el}
157
+ class={twMerge(!unstyled && CLS, classProp)}
158
+ role="tablist"
159
+ {...rest}
160
+ >
161
+ {#each items as item (item.id)}
162
+ {@const props = {
163
+ role: "tab",
164
+ ["aria-selected"]: value === item.id,
165
+ ["aria-disabled"]: item.disabled || disabled || undefined,
166
+ tabindex: value === item.id ? 0 : -1,
167
+ class: getButtonClass(item),
168
+ onclick: () => selectItem(item),
169
+ onkeydown: (e: KeyboardEvent) => handleKeydown(e, item),
170
+ }}
171
+ <li class={twMerge(CLS_ITEM, classItem)} role="presentation">
172
+ {#if item.href}
173
+ <a href={item.href} {...props} bind:this={buttonEls[item.id]}>
174
+ <Thc thc={item.label} />
175
+ </a>
176
+ {:else}
177
+ <button type="button" {...props} bind:this={buttonEls[item.id]}>
178
+ <Thc thc={item.label} />
179
+ </button>
180
+ {/if}
181
+ </li>
182
+ {/each}
183
+ </ul>
184
+ {/if}
@@ -0,0 +1,28 @@
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ import type { THC } from "../Thc/index.js";
3
+ export interface TabbedMenuItem {
4
+ id: string | number;
5
+ label: THC;
6
+ disabled?: boolean;
7
+ class?: string;
8
+ data?: Record<string, any>;
9
+ onSelect?: () => void | boolean;
10
+ href?: string;
11
+ }
12
+ export interface Props extends Omit<HTMLAttributes<HTMLUListElement>, "children"> {
13
+ items: TabbedMenuItem[];
14
+ value?: string | number;
15
+ disabled?: boolean;
16
+ onSelect?: (item: TabbedMenuItem) => void;
17
+ class?: string;
18
+ classItem?: string;
19
+ classButton?: string;
20
+ classButtonActive?: string;
21
+ classButtonDisabled?: string;
22
+ unstyled?: boolean;
23
+ el?: HTMLUListElement;
24
+ }
25
+ import "./index.css";
26
+ declare const TabbedMenu: import("svelte").Component<Props, {}, "value" | "el">;
27
+ type TabbedMenu = ReturnType<typeof TabbedMenu>;
28
+ export default TabbedMenu;
@@ -0,0 +1,23 @@
1
+ /* prettier-ignore */
2
+ @theme inline {
3
+ /* Tab button background */
4
+ --color-tabbed-menu-tab-bg: var(--color-tabbed-menu-tab-bg, var(--color-neutral-100));
5
+ --color-tabbed-menu-tab-bg-dark: var(--color-tabbed-menu-tab-bg-dark, var(--color-neutral-700));
6
+
7
+ /* Tab button text */
8
+ --color-tabbed-menu-tab-text: var(--color-tabbed-menu-tab-text, var(--color-neutral-600));
9
+ --color-tabbed-menu-tab-text-dark: var(--color-tabbed-menu-tab-text-dark, var(--color-neutral-300));
10
+
11
+ /* Active tab background */
12
+ --color-tabbed-menu-tab-active-bg: var(--color-tabbed-menu-tab-active-bg, var(--color-white));
13
+ --color-tabbed-menu-tab-active-bg-dark: var(--color-tabbed-menu-tab-active-bg-dark, var(--color-neutral-800));
14
+
15
+ /* Active tab text */
16
+ --color-tabbed-menu-tab-active-text: var(--color-tabbed-menu-tab-active-text, var(--color-neutral-900));
17
+ --color-tabbed-menu-tab-active-text-dark: var(--color-tabbed-menu-tab-active-text-dark, var(--color-white));
18
+
19
+ /* Border */
20
+ --color-tabbed-menu-border: var(--color-tabbed-menu-border, var(--color-neutral-300));
21
+ --color-tabbed-menu-border-dark: var(--color-tabbed-menu-border-dark, var(--color-neutral-600));
22
+
23
+ }
@@ -0,0 +1 @@
1
+ export { default as TabbedMenu, type Props as TabbedMenuProps, type TabbedMenuItem, } from "./TabbedMenu.svelte";
@@ -0,0 +1 @@
1
+ export { default as TabbedMenu, } from "./TabbedMenu.svelte";
package/dist/index.css CHANGED
@@ -16,6 +16,7 @@ so, since we need to override, sticking with that */
16
16
  @import "./components/Notifications/index.css";
17
17
  @import "./components/Progress/index.css";
18
18
  @import "./components/Switch/index.css";
19
+ @import "./components/TabbedMenu/index.css";
19
20
  @import "./components/TwCheck/index.css";
20
21
  }
21
22
 
package/dist/index.d.ts CHANGED
@@ -46,6 +46,7 @@ export * from "./components/Skeleton/index.js";
46
46
  export * from "./components/SlidingPanels/index.js";
47
47
  export * from "./components/Spinner/index.js";
48
48
  export * from "./components/Switch/index.js";
49
+ export * from "./components/TabbedMenu/index.js";
49
50
  export * from "./components/Thc/index.js";
50
51
  export * from "./components/TwCheck/index.js";
51
52
  export * from "./components/TypeaheadInput/index.js";
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ export * from "./components/Skeleton/index.js";
47
47
  export * from "./components/SlidingPanels/index.js";
48
48
  export * from "./components/Spinner/index.js";
49
49
  export * from "./components/Switch/index.js";
50
+ export * from "./components/TabbedMenu/index.js";
50
51
  export * from "./components/Thc/index.js";
51
52
  export * from "./components/TwCheck/index.js";
52
53
  export * from "./components/TypeaheadInput/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.46.0",
3
+ "version": "2.48.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",