@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.
- package/dist/components/Switch/Switch.svelte +1 -1
- package/dist/components/TabbedMenu/README.md +84 -0
- package/dist/components/TabbedMenu/TabbedMenu.svelte +184 -0
- package/dist/components/TabbedMenu/TabbedMenu.svelte.d.ts +28 -0
- package/dist/components/TabbedMenu/index.css +23 -0
- package/dist/components/TabbedMenu/index.d.ts +1 -0
- package/dist/components/TabbedMenu/index.js +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
transition-colors duration-100
|
|
104
104
|
|
|
105
105
|
hover:brightness-105 active:brightness-95
|
|
106
|
-
data-[disabled=true]
|
|
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";
|