@marianmeres/stuic 2.45.0 → 2.47.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.
@@ -543,7 +543,15 @@
543
543
  </div>
544
544
  {/if}
545
545
 
546
- <div class="absolute top-4 right-4 flex items-center space-x-3">
546
+ <div class="absolute top-4 left-4 right-4 flex items-center justify-between gap-3">
547
+ {#if !noName && previewAsset?.name}
548
+ <span class="truncate bg-white px-1 rounded">
549
+ {previewAsset?.name}
550
+ </span>
551
+ {:else}
552
+ <span></span>
553
+ {/if}
554
+ <div class="flex items-center space-x-3 shrink-0">
547
555
  {#if previewAsset.isImage}
548
556
  <button
549
557
  class={twMerge(TOP_BUTTON_CLS, classControls)}
@@ -602,14 +610,9 @@
602
610
  >
603
611
  <X />
604
612
  </button>
613
+ </div>
605
614
  </div>
606
615
 
607
- {#if !noName && previewAsset?.name}
608
- <span class="absolute top-4 left-4 bg-white px-1 rounded">
609
- {previewAsset?.name}
610
- </span>
611
- {/if}
612
-
613
616
  {#if assets.length > 1}
614
617
  {#if !noName && dotTooltip}
615
618
  <div
@@ -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,182 @@
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
+ `;
81
+
82
+ const CLS_BUTTON_ACTIVE = `
83
+ bg-tabbed-menu-tab-active-bg dark:bg-tabbed-menu-tab-active-bg-dark
84
+ text-tabbed-menu-tab-active-text dark:text-tabbed-menu-tab-active-text-dark
85
+ font-medium
86
+ `;
87
+
88
+ const CLS_BUTTON_DISABLED = `
89
+ opacity-50 cursor-not-allowed
90
+ pointer-events-none
91
+ `;
92
+
93
+ let buttonEls = $state<Record<string | number, HTMLButtonElement | HTMLAnchorElement>>(
94
+ {}
95
+ );
96
+
97
+ function selectItem(item: TabbedMenuItem) {
98
+ if (item.disabled || disabled) return;
99
+ // item-level handler takes priority
100
+ if (item.onSelect?.() === false) return;
101
+ value = item.id;
102
+ onSelect?.(item);
103
+ }
104
+
105
+ function handleKeydown(e: KeyboardEvent, item: TabbedMenuItem) {
106
+ const enabledItems = items.filter((i) => !i.disabled && !disabled);
107
+ const currentEnabledIndex = enabledItems.findIndex((i) => i.id === item.id);
108
+
109
+ if (["ArrowRight", "ArrowDown"].includes(e.key)) {
110
+ e.preventDefault();
111
+ const nextIndex = (currentEnabledIndex + 1) % enabledItems.length;
112
+ const nextItem = enabledItems[nextIndex];
113
+ buttonEls[nextItem.id]?.focus();
114
+ } else if (["ArrowLeft", "ArrowUp"].includes(e.key)) {
115
+ e.preventDefault();
116
+ const prevIndex =
117
+ (currentEnabledIndex - 1 + enabledItems.length) % enabledItems.length;
118
+ const prevItem = enabledItems[prevIndex];
119
+ buttonEls[prevItem.id]?.focus();
120
+ } else if (["Enter", " "].includes(e.key)) {
121
+ // For anchors, let Enter trigger native navigation (onclick still fires)
122
+ if (e.key === "Enter" && item.href) return;
123
+ e.preventDefault();
124
+ selectItem(item);
125
+ } else if (e.key === "Home") {
126
+ e.preventDefault();
127
+ const firstItem = enabledItems[0];
128
+ if (firstItem) buttonEls[firstItem.id]?.focus();
129
+ } else if (e.key === "End") {
130
+ e.preventDefault();
131
+ const lastItem = enabledItems[enabledItems.length - 1];
132
+ if (lastItem) buttonEls[lastItem.id]?.focus();
133
+ }
134
+ }
135
+
136
+ function getButtonClass(item: TabbedMenuItem): string {
137
+ const isActive = value === item.id;
138
+ const isDisabled = item.disabled || disabled;
139
+
140
+ return twMerge(
141
+ !unstyled && CLS_BUTTON,
142
+ classButton,
143
+ isActive && !unstyled && CLS_BUTTON_ACTIVE,
144
+ isActive && classButtonActive,
145
+ isDisabled && !unstyled && CLS_BUTTON_DISABLED,
146
+ isDisabled && classButtonDisabled,
147
+ item.class
148
+ );
149
+ }
150
+ </script>
151
+
152
+ {#if items.length}
153
+ <ul
154
+ bind:this={el}
155
+ class={twMerge(!unstyled && CLS, classProp)}
156
+ role="tablist"
157
+ {...rest}
158
+ >
159
+ {#each items as item (item.id)}
160
+ {@const props = {
161
+ role: "tab",
162
+ ["aria-selected"]: value === item.id,
163
+ ["aria-disabled"]: item.disabled || disabled || undefined,
164
+ tabindex: value === item.id ? 0 : -1,
165
+ class: getButtonClass(item),
166
+ onclick: () => selectItem(item),
167
+ onkeydown: (e: KeyboardEvent) => handleKeydown(e, item),
168
+ }}
169
+ <li class={twMerge(CLS_ITEM, classItem)} role="presentation">
170
+ {#if item.href}
171
+ <a href={item.href} {...props} bind:this={buttonEls[item.id]}>
172
+ <Thc thc={item.label} />
173
+ </a>
174
+ {:else}
175
+ <button type="button" {...props} bind:this={buttonEls[item.id]}>
176
+ <Thc thc={item.label} />
177
+ </button>
178
+ {/if}
179
+ </li>
180
+ {/each}
181
+ </ul>
182
+ {/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.45.0",
3
+ "version": "2.47.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",