bref-ui 2.1.4 → 2.2.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/combobox/input.svelte.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/tabs/item.svelte +125 -0
- package/dist/tabs/item.svelte.d.ts +4 -0
- package/dist/tabs/registry.json +6 -0
- package/dist/tabs/tabs.svelte +124 -0
- package/dist/tabs/tabs.svelte.d.ts +4 -0
- package/dist/tabs/types.d.ts +48 -0
- package/dist/tabs/types.js +1 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
- package/readme.md +0 -13
- package/src/lib/index.ts +1 -0
- package/src/lib/tabs/item.svelte +125 -0
- package/src/lib/tabs/registry.json +6 -0
- package/src/lib/tabs/tabs.svelte +124 -0
- package/src/lib/tabs/types.ts +55 -0
- package/src/lib/types.ts +1 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ComboboxInputProps } from './types.js';
|
|
2
|
-
declare const Input: import("svelte").Component<ComboboxInputProps, {}, "
|
|
2
|
+
declare const Input: import("svelte").Component<ComboboxInputProps, {}, "input" | "open" | "activeId">;
|
|
3
3
|
type Input = ReturnType<typeof Input>;
|
|
4
4
|
export default Input;
|
package/dist/index.d.ts
CHANGED
|
@@ -18,3 +18,4 @@ export { default as Pill } from './pill/pill.svelte';
|
|
|
18
18
|
export { default as PillGroup } from './pill-group/pill-group.svelte';
|
|
19
19
|
export { default as PillChoiceGroup } from './pill-choice-group/pill-choice-group.svelte';
|
|
20
20
|
export { default as Select } from './select/select.svelte';
|
|
21
|
+
export { default as Tabs } from './tabs/tabs.svelte';
|
package/dist/index.js
CHANGED
|
@@ -18,3 +18,4 @@ export { default as Pill } from './pill/pill.svelte';
|
|
|
18
18
|
export { default as PillGroup } from './pill-group/pill-group.svelte';
|
|
19
19
|
export { default as PillChoiceGroup } from './pill-choice-group/pill-choice-group.svelte';
|
|
20
20
|
export { default as Select } from './select/select.svelte';
|
|
21
|
+
export { default as Tabs } from './tabs/tabs.svelte';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
3
|
+
import Icon from '../icon/icon.svelte';
|
|
4
|
+
import type { TabItemProps } from './types.js';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
label,
|
|
8
|
+
icon,
|
|
9
|
+
disabled = false,
|
|
10
|
+
selected,
|
|
11
|
+
orientation = 'horizontal',
|
|
12
|
+
size = 'fit',
|
|
13
|
+
filled = false,
|
|
14
|
+
onSelect,
|
|
15
|
+
transitionIn = () => ({}),
|
|
16
|
+
transitionOut = () => ({})
|
|
17
|
+
}: TabItemProps = $props();
|
|
18
|
+
const smallScreen = new MediaQuery('(max-width: 575px)', false);
|
|
19
|
+
const showLabel = $derived(!smallScreen.current || (orientation === 'horizontal' && selected));
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
role="tab"
|
|
25
|
+
aria-label={label}
|
|
26
|
+
aria-selected={selected ?? false}
|
|
27
|
+
data-size={orientation === 'horizontal' ? size : undefined}
|
|
28
|
+
data-orientation={orientation}
|
|
29
|
+
data-icon-only={!showLabel}
|
|
30
|
+
tabindex={selected === undefined ? undefined : selected ? 0 : -1}
|
|
31
|
+
{disabled}
|
|
32
|
+
onclick={onSelect}
|
|
33
|
+
>
|
|
34
|
+
<Icon name={icon} {filled} />
|
|
35
|
+
{#if showLabel}<span>{label}</span>{/if}
|
|
36
|
+
{#if selected}
|
|
37
|
+
<i aria-hidden="true" in:transitionIn out:transitionOut></i>
|
|
38
|
+
{/if}
|
|
39
|
+
</button>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
button {
|
|
43
|
+
position: relative;
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
flex: 0 0 auto;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 0.5rem;
|
|
49
|
+
width: max-content;
|
|
50
|
+
min-width: 44px;
|
|
51
|
+
min-height: 48px;
|
|
52
|
+
padding: 0.75rem 1rem;
|
|
53
|
+
border: 0;
|
|
54
|
+
background: transparent;
|
|
55
|
+
color: var(--color-muted);
|
|
56
|
+
font: inherit;
|
|
57
|
+
font-weight: 500;
|
|
58
|
+
line-height: 1.5;
|
|
59
|
+
text-align: start;
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
transition:
|
|
63
|
+
color 150ms ease,
|
|
64
|
+
background-color 150ms ease;
|
|
65
|
+
}
|
|
66
|
+
button[data-size='fill'] {
|
|
67
|
+
flex: 1 1 auto;
|
|
68
|
+
width: auto;
|
|
69
|
+
max-width: 100%;
|
|
70
|
+
padding-inline: 0.5rem;
|
|
71
|
+
}
|
|
72
|
+
button[data-orientation='vertical'] {
|
|
73
|
+
width: 100%;
|
|
74
|
+
max-width: 100%;
|
|
75
|
+
}
|
|
76
|
+
button[data-icon-only='true'] {
|
|
77
|
+
justify-content: center;
|
|
78
|
+
padding-inline: 0.75rem;
|
|
79
|
+
}
|
|
80
|
+
button[aria-selected='true'],
|
|
81
|
+
button:hover:not(:disabled) {
|
|
82
|
+
color: var(--color-foreground);
|
|
83
|
+
}
|
|
84
|
+
button:hover:not(:disabled) {
|
|
85
|
+
background: color-mix(in srgb, var(--color-foreground) 3%, transparent);
|
|
86
|
+
}
|
|
87
|
+
button:focus-visible {
|
|
88
|
+
outline: 2px solid var(--color-primary);
|
|
89
|
+
outline-offset: -4px;
|
|
90
|
+
}
|
|
91
|
+
button:disabled {
|
|
92
|
+
opacity: 0.45;
|
|
93
|
+
cursor: not-allowed;
|
|
94
|
+
}
|
|
95
|
+
span {
|
|
96
|
+
display: block;
|
|
97
|
+
min-width: 0;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
text-overflow: ellipsis;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
}
|
|
102
|
+
i {
|
|
103
|
+
position: absolute;
|
|
104
|
+
inset-block: var(--tabs-indicator-block-start) 0;
|
|
105
|
+
inset-inline: var(--tabs-indicator-inline-start) 0;
|
|
106
|
+
width: var(--tabs-indicator-width);
|
|
107
|
+
height: var(--tabs-indicator-height);
|
|
108
|
+
background: var(--color-primary);
|
|
109
|
+
pointer-events: none;
|
|
110
|
+
}
|
|
111
|
+
@media (prefers-reduced-motion: reduce) {
|
|
112
|
+
button {
|
|
113
|
+
transition: none;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
@media (forced-colors: active) {
|
|
117
|
+
button[aria-selected='true'] {
|
|
118
|
+
outline: 2px solid Highlight;
|
|
119
|
+
outline-offset: -2px;
|
|
120
|
+
}
|
|
121
|
+
button:focus-visible {
|
|
122
|
+
outline-style: dashed;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { cubicOut } from 'svelte/easing';
|
|
4
|
+
import { crossfade } from 'svelte/transition';
|
|
5
|
+
import Item from './item.svelte';
|
|
6
|
+
import type { TabsProps } from './types.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
items,
|
|
10
|
+
orientation = 'horizontal',
|
|
11
|
+
size = 'fit',
|
|
12
|
+
value = $bindable(),
|
|
13
|
+
onChange,
|
|
14
|
+
label = 'Tabs',
|
|
15
|
+
filled = false,
|
|
16
|
+
transitionIn,
|
|
17
|
+
transitionOut
|
|
18
|
+
}: TabsProps = $props();
|
|
19
|
+
let strip: HTMLDivElement | undefined = $state();
|
|
20
|
+
const layout = $derived(orientation === 'horizontal' ? { orientation, size } : { orientation });
|
|
21
|
+
const [send, receive] = crossfade({
|
|
22
|
+
duration: () => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 220),
|
|
23
|
+
easing: cubicOut
|
|
24
|
+
});
|
|
25
|
+
const intro = (node: HTMLElement) =>
|
|
26
|
+
transitionIn?.(node) ?? receive(node, { key: 'selected-tab' });
|
|
27
|
+
const outro = (node: HTMLElement) => transitionOut?.(node) ?? send(node, { key: 'selected-tab' });
|
|
28
|
+
const select = async (id: string) => {
|
|
29
|
+
if (id === value || !items.some((item) => item.id === id && !item.disabled)) return;
|
|
30
|
+
value = id;
|
|
31
|
+
onChange?.(id);
|
|
32
|
+
await tick();
|
|
33
|
+
strip
|
|
34
|
+
?.querySelector<HTMLButtonElement>('[aria-selected="true"]')
|
|
35
|
+
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
36
|
+
};
|
|
37
|
+
const navigate = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
|
38
|
+
const buttons = [
|
|
39
|
+
...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')
|
|
40
|
+
];
|
|
41
|
+
const index = buttons.indexOf(event.target as HTMLButtonElement);
|
|
42
|
+
if (index < 0) return;
|
|
43
|
+
const vertical = orientation === 'vertical';
|
|
44
|
+
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
|
|
45
|
+
const nextKey = vertical ? 'ArrowDown' : rtl ? 'ArrowLeft' : 'ArrowRight';
|
|
46
|
+
const previousKey = vertical ? 'ArrowUp' : rtl ? 'ArrowRight' : 'ArrowLeft';
|
|
47
|
+
let next: number;
|
|
48
|
+
if (event.key === 'Home') next = 0;
|
|
49
|
+
else if (event.key === 'End') next = buttons.length - 1;
|
|
50
|
+
else if (event.key === nextKey) next = (index + 1) % buttons.length;
|
|
51
|
+
else if (event.key === previousKey) next = (index - 1 + buttons.length) % buttons.length;
|
|
52
|
+
else return;
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
buttons[next].focus();
|
|
55
|
+
buttons[next].click();
|
|
56
|
+
};
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<div
|
|
60
|
+
bind:this={strip}
|
|
61
|
+
role="tablist"
|
|
62
|
+
tabindex={-1}
|
|
63
|
+
aria-label={label}
|
|
64
|
+
aria-orientation={orientation}
|
|
65
|
+
data-orientation={orientation}
|
|
66
|
+
data-size={orientation === 'horizontal' ? size : undefined}
|
|
67
|
+
onkeydown={navigate}
|
|
68
|
+
>
|
|
69
|
+
{#each items as item (item.id)}
|
|
70
|
+
<Item
|
|
71
|
+
{...item}
|
|
72
|
+
selected={value === undefined ? undefined : value === item.id}
|
|
73
|
+
{...layout}
|
|
74
|
+
{filled}
|
|
75
|
+
onSelect={() => select(item.id)}
|
|
76
|
+
transitionIn={intro}
|
|
77
|
+
transitionOut={outro}
|
|
78
|
+
/>
|
|
79
|
+
{/each}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
div {
|
|
84
|
+
--tabs-indicator-block-start: auto;
|
|
85
|
+
--tabs-indicator-inline-start: 0;
|
|
86
|
+
--tabs-indicator-width: auto;
|
|
87
|
+
--tabs-indicator-height: 2px;
|
|
88
|
+
box-sizing: border-box;
|
|
89
|
+
display: inline-flex;
|
|
90
|
+
align-self: start;
|
|
91
|
+
align-items: stretch;
|
|
92
|
+
justify-self: start;
|
|
93
|
+
width: max-content;
|
|
94
|
+
max-width: 100%;
|
|
95
|
+
min-width: 0;
|
|
96
|
+
border: 0;
|
|
97
|
+
border-block-end: 1px solid color-mix(in srgb, var(--color-foreground) 12%, transparent);
|
|
98
|
+
overflow: auto;
|
|
99
|
+
overscroll-behavior-inline: contain;
|
|
100
|
+
scrollbar-width: thin;
|
|
101
|
+
}
|
|
102
|
+
div[data-size='fill'] {
|
|
103
|
+
width: 100%;
|
|
104
|
+
}
|
|
105
|
+
div[data-orientation='vertical'] {
|
|
106
|
+
--tabs-indicator-block-start: 0;
|
|
107
|
+
--tabs-indicator-inline-start: auto;
|
|
108
|
+
--tabs-indicator-width: 2px;
|
|
109
|
+
--tabs-indicator-height: auto;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
max-height: 100%;
|
|
112
|
+
min-height: 0;
|
|
113
|
+
border-block-end: 0;
|
|
114
|
+
border-inline-end: 1px solid color-mix(in srgb, var(--color-foreground) 12%, transparent);
|
|
115
|
+
overflow-x: hidden;
|
|
116
|
+
overflow-y: auto;
|
|
117
|
+
overscroll-behavior-block: contain;
|
|
118
|
+
}
|
|
119
|
+
@media (forced-colors: active) {
|
|
120
|
+
div {
|
|
121
|
+
border-color: ButtonBorder;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Dimension, IconName } from '../types.ts';
|
|
2
|
+
import type { TransitionConfig } from 'svelte/transition';
|
|
3
|
+
/** An indicator transition, including deferred Svelte transition configurations. */
|
|
4
|
+
export type TabTransition = (node: HTMLElement) => TransitionConfig | (() => TransitionConfig);
|
|
5
|
+
/** Data for a tab in the strip. */
|
|
6
|
+
export interface TabItemDataProps {
|
|
7
|
+
/** Unique tab ID used for selection. */
|
|
8
|
+
id: string;
|
|
9
|
+
/** Accessible name and tab text; small screens show only the selected horizontal label. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Material Symbols icon displayed beside the label. */
|
|
12
|
+
icon: IconName;
|
|
13
|
+
/** Prevent selection of this tab. */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Props for a rendered tab item, including its selection state. */
|
|
17
|
+
export type TabItemProps = TabItemDataProps & {
|
|
18
|
+
/** Whether this item is selected, derived from the strip's value. */
|
|
19
|
+
selected?: boolean;
|
|
20
|
+
/** Use filled glyphs for all tab icons. */
|
|
21
|
+
filled?: boolean;
|
|
22
|
+
/** Select this item when activated. */
|
|
23
|
+
onSelect: () => void;
|
|
24
|
+
/** Animate the selected indicator into this item. */
|
|
25
|
+
transitionIn?: TabTransition;
|
|
26
|
+
/** Animate the selected indicator out of this item. */
|
|
27
|
+
transitionOut?: TabTransition;
|
|
28
|
+
} & OrientationProps;
|
|
29
|
+
type OrientationProps = {
|
|
30
|
+
orientation?: 'horizontal';
|
|
31
|
+
/** Fit content and scroll on overflow, or fill the parent and truncate labels. */
|
|
32
|
+
size?: Dimension;
|
|
33
|
+
} | {
|
|
34
|
+
orientation: 'vertical';
|
|
35
|
+
size?: never;
|
|
36
|
+
};
|
|
37
|
+
/** A horizontal or vertical tab strip; the consumer owns the content. */
|
|
38
|
+
export type TabsProps = {
|
|
39
|
+
/** Tabs in display order. */
|
|
40
|
+
items: readonly TabItemDataProps[];
|
|
41
|
+
/** ID of the selected tab. */
|
|
42
|
+
value?: string;
|
|
43
|
+
/** Called with the tab ID when user interaction changes selection. */
|
|
44
|
+
onChange?: (id: string) => void;
|
|
45
|
+
/** Accessible name for the tab list. */
|
|
46
|
+
label?: string;
|
|
47
|
+
} & OrientationProps & Pick<TabItemProps, 'filled' | 'transitionIn' | 'transitionOut'>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/types.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type StatusColor = 'info' | 'success' | 'warning' | 'error';
|
|
|
5
5
|
export type Color = 'primary' | 'secondary' | 'foreground' | 'background' | 'muted' | StatusColor;
|
|
6
6
|
export type Orientation = 'horizontal' | 'vertical';
|
|
7
7
|
export type Dimension = 'fit' | 'fill';
|
|
8
|
+
export type { TabItemProps, TabItemDataProps, TabsProps, TabTransition } from './tabs/types.js';
|
|
8
9
|
export type { ButtonProps } from './button/types.js';
|
|
9
10
|
export type { ComboboxProps, ComboboxNativeProps, ComboboxInputProps, ComboboxListProps, ComboboxOptionProps, ComboboxFormProps } from './combobox/types.js';
|
|
10
11
|
export type * from './icon/types.js';
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -166,19 +166,6 @@ Run `bun run dev` in the repository for the gallery and current API documentatio
|
|
|
166
166
|
Explore the [component exports](src/lib/index.ts) and [component source](src/lib)
|
|
167
167
|
to inspect and customize the implementation.
|
|
168
168
|
|
|
169
|
-
## Themes and examples
|
|
170
|
-
|
|
171
|
-
Explore [bref-ui-themes](https://github.com/feuersteiner/bref-ui-themes) for reusable
|
|
172
|
-
Svelte themes and interactive example sites built with Bref-ui. Browse the
|
|
173
|
-
[live showcase](https://feuersteiner.github.io/bref-ui-themes/) or try the demos:
|
|
174
|
-
|
|
175
|
-
- [Relay](https://feuersteiner.github.io/bref-ui-themes/relay): a project and task
|
|
176
|
-
management app with search, filtering and task editing.
|
|
177
|
-
- [Forma](https://feuersteiner.github.io/bref-ui-themes/forma): a furniture store
|
|
178
|
-
with product filtering, a shopping cart and simulated checkout.
|
|
179
|
-
- [Elsewhere](https://feuersteiner.github.io/bref-ui-themes/elsewhere): a website
|
|
180
|
-
audit app with simulated runs, history and finding review.
|
|
181
|
-
|
|
182
169
|
## Roadmap
|
|
183
170
|
|
|
184
171
|
- **Component foundation:** inputs/textarea, pills, tree views, loading/progress,
|
package/src/lib/index.ts
CHANGED
|
@@ -18,3 +18,4 @@ export { default as Pill } from './pill/pill.svelte';
|
|
|
18
18
|
export { default as PillGroup } from './pill-group/pill-group.svelte';
|
|
19
19
|
export { default as PillChoiceGroup } from './pill-choice-group/pill-choice-group.svelte';
|
|
20
20
|
export { default as Select } from './select/select.svelte';
|
|
21
|
+
export { default as Tabs } from './tabs/tabs.svelte';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { MediaQuery } from 'svelte/reactivity';
|
|
3
|
+
import Icon from '../icon/icon.svelte';
|
|
4
|
+
import type { TabItemProps } from './types.js';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
label,
|
|
8
|
+
icon,
|
|
9
|
+
disabled = false,
|
|
10
|
+
selected,
|
|
11
|
+
orientation = 'horizontal',
|
|
12
|
+
size = 'fit',
|
|
13
|
+
filled = false,
|
|
14
|
+
onSelect,
|
|
15
|
+
transitionIn = () => ({}),
|
|
16
|
+
transitionOut = () => ({})
|
|
17
|
+
}: TabItemProps = $props();
|
|
18
|
+
const smallScreen = new MediaQuery('(max-width: 575px)', false);
|
|
19
|
+
const showLabel = $derived(!smallScreen.current || (orientation === 'horizontal' && selected));
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
role="tab"
|
|
25
|
+
aria-label={label}
|
|
26
|
+
aria-selected={selected ?? false}
|
|
27
|
+
data-size={orientation === 'horizontal' ? size : undefined}
|
|
28
|
+
data-orientation={orientation}
|
|
29
|
+
data-icon-only={!showLabel}
|
|
30
|
+
tabindex={selected === undefined ? undefined : selected ? 0 : -1}
|
|
31
|
+
{disabled}
|
|
32
|
+
onclick={onSelect}
|
|
33
|
+
>
|
|
34
|
+
<Icon name={icon} {filled} />
|
|
35
|
+
{#if showLabel}<span>{label}</span>{/if}
|
|
36
|
+
{#if selected}
|
|
37
|
+
<i aria-hidden="true" in:transitionIn out:transitionOut></i>
|
|
38
|
+
{/if}
|
|
39
|
+
</button>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
button {
|
|
43
|
+
position: relative;
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
flex: 0 0 auto;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 0.5rem;
|
|
49
|
+
width: max-content;
|
|
50
|
+
min-width: 44px;
|
|
51
|
+
min-height: 48px;
|
|
52
|
+
padding: 0.75rem 1rem;
|
|
53
|
+
border: 0;
|
|
54
|
+
background: transparent;
|
|
55
|
+
color: var(--color-muted);
|
|
56
|
+
font: inherit;
|
|
57
|
+
font-weight: 500;
|
|
58
|
+
line-height: 1.5;
|
|
59
|
+
text-align: start;
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
transition:
|
|
63
|
+
color 150ms ease,
|
|
64
|
+
background-color 150ms ease;
|
|
65
|
+
}
|
|
66
|
+
button[data-size='fill'] {
|
|
67
|
+
flex: 1 1 auto;
|
|
68
|
+
width: auto;
|
|
69
|
+
max-width: 100%;
|
|
70
|
+
padding-inline: 0.5rem;
|
|
71
|
+
}
|
|
72
|
+
button[data-orientation='vertical'] {
|
|
73
|
+
width: 100%;
|
|
74
|
+
max-width: 100%;
|
|
75
|
+
}
|
|
76
|
+
button[data-icon-only='true'] {
|
|
77
|
+
justify-content: center;
|
|
78
|
+
padding-inline: 0.75rem;
|
|
79
|
+
}
|
|
80
|
+
button[aria-selected='true'],
|
|
81
|
+
button:hover:not(:disabled) {
|
|
82
|
+
color: var(--color-foreground);
|
|
83
|
+
}
|
|
84
|
+
button:hover:not(:disabled) {
|
|
85
|
+
background: color-mix(in srgb, var(--color-foreground) 3%, transparent);
|
|
86
|
+
}
|
|
87
|
+
button:focus-visible {
|
|
88
|
+
outline: 2px solid var(--color-primary);
|
|
89
|
+
outline-offset: -4px;
|
|
90
|
+
}
|
|
91
|
+
button:disabled {
|
|
92
|
+
opacity: 0.45;
|
|
93
|
+
cursor: not-allowed;
|
|
94
|
+
}
|
|
95
|
+
span {
|
|
96
|
+
display: block;
|
|
97
|
+
min-width: 0;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
text-overflow: ellipsis;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
}
|
|
102
|
+
i {
|
|
103
|
+
position: absolute;
|
|
104
|
+
inset-block: var(--tabs-indicator-block-start) 0;
|
|
105
|
+
inset-inline: var(--tabs-indicator-inline-start) 0;
|
|
106
|
+
width: var(--tabs-indicator-width);
|
|
107
|
+
height: var(--tabs-indicator-height);
|
|
108
|
+
background: var(--color-primary);
|
|
109
|
+
pointer-events: none;
|
|
110
|
+
}
|
|
111
|
+
@media (prefers-reduced-motion: reduce) {
|
|
112
|
+
button {
|
|
113
|
+
transition: none;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
@media (forced-colors: active) {
|
|
117
|
+
button[aria-selected='true'] {
|
|
118
|
+
outline: 2px solid Highlight;
|
|
119
|
+
outline-offset: -2px;
|
|
120
|
+
}
|
|
121
|
+
button:focus-visible {
|
|
122
|
+
outline-style: dashed;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { cubicOut } from 'svelte/easing';
|
|
4
|
+
import { crossfade } from 'svelte/transition';
|
|
5
|
+
import Item from './item.svelte';
|
|
6
|
+
import type { TabsProps } from './types.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
items,
|
|
10
|
+
orientation = 'horizontal',
|
|
11
|
+
size = 'fit',
|
|
12
|
+
value = $bindable(),
|
|
13
|
+
onChange,
|
|
14
|
+
label = 'Tabs',
|
|
15
|
+
filled = false,
|
|
16
|
+
transitionIn,
|
|
17
|
+
transitionOut
|
|
18
|
+
}: TabsProps = $props();
|
|
19
|
+
let strip: HTMLDivElement | undefined = $state();
|
|
20
|
+
const layout = $derived(orientation === 'horizontal' ? { orientation, size } : { orientation });
|
|
21
|
+
const [send, receive] = crossfade({
|
|
22
|
+
duration: () => (matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 220),
|
|
23
|
+
easing: cubicOut
|
|
24
|
+
});
|
|
25
|
+
const intro = (node: HTMLElement) =>
|
|
26
|
+
transitionIn?.(node) ?? receive(node, { key: 'selected-tab' });
|
|
27
|
+
const outro = (node: HTMLElement) => transitionOut?.(node) ?? send(node, { key: 'selected-tab' });
|
|
28
|
+
const select = async (id: string) => {
|
|
29
|
+
if (id === value || !items.some((item) => item.id === id && !item.disabled)) return;
|
|
30
|
+
value = id;
|
|
31
|
+
onChange?.(id);
|
|
32
|
+
await tick();
|
|
33
|
+
strip
|
|
34
|
+
?.querySelector<HTMLButtonElement>('[aria-selected="true"]')
|
|
35
|
+
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
36
|
+
};
|
|
37
|
+
const navigate = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
|
38
|
+
const buttons = [
|
|
39
|
+
...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')
|
|
40
|
+
];
|
|
41
|
+
const index = buttons.indexOf(event.target as HTMLButtonElement);
|
|
42
|
+
if (index < 0) return;
|
|
43
|
+
const vertical = orientation === 'vertical';
|
|
44
|
+
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
|
|
45
|
+
const nextKey = vertical ? 'ArrowDown' : rtl ? 'ArrowLeft' : 'ArrowRight';
|
|
46
|
+
const previousKey = vertical ? 'ArrowUp' : rtl ? 'ArrowRight' : 'ArrowLeft';
|
|
47
|
+
let next: number;
|
|
48
|
+
if (event.key === 'Home') next = 0;
|
|
49
|
+
else if (event.key === 'End') next = buttons.length - 1;
|
|
50
|
+
else if (event.key === nextKey) next = (index + 1) % buttons.length;
|
|
51
|
+
else if (event.key === previousKey) next = (index - 1 + buttons.length) % buttons.length;
|
|
52
|
+
else return;
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
buttons[next].focus();
|
|
55
|
+
buttons[next].click();
|
|
56
|
+
};
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<div
|
|
60
|
+
bind:this={strip}
|
|
61
|
+
role="tablist"
|
|
62
|
+
tabindex={-1}
|
|
63
|
+
aria-label={label}
|
|
64
|
+
aria-orientation={orientation}
|
|
65
|
+
data-orientation={orientation}
|
|
66
|
+
data-size={orientation === 'horizontal' ? size : undefined}
|
|
67
|
+
onkeydown={navigate}
|
|
68
|
+
>
|
|
69
|
+
{#each items as item (item.id)}
|
|
70
|
+
<Item
|
|
71
|
+
{...item}
|
|
72
|
+
selected={value === undefined ? undefined : value === item.id}
|
|
73
|
+
{...layout}
|
|
74
|
+
{filled}
|
|
75
|
+
onSelect={() => select(item.id)}
|
|
76
|
+
transitionIn={intro}
|
|
77
|
+
transitionOut={outro}
|
|
78
|
+
/>
|
|
79
|
+
{/each}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
div {
|
|
84
|
+
--tabs-indicator-block-start: auto;
|
|
85
|
+
--tabs-indicator-inline-start: 0;
|
|
86
|
+
--tabs-indicator-width: auto;
|
|
87
|
+
--tabs-indicator-height: 2px;
|
|
88
|
+
box-sizing: border-box;
|
|
89
|
+
display: inline-flex;
|
|
90
|
+
align-self: start;
|
|
91
|
+
align-items: stretch;
|
|
92
|
+
justify-self: start;
|
|
93
|
+
width: max-content;
|
|
94
|
+
max-width: 100%;
|
|
95
|
+
min-width: 0;
|
|
96
|
+
border: 0;
|
|
97
|
+
border-block-end: 1px solid color-mix(in srgb, var(--color-foreground) 12%, transparent);
|
|
98
|
+
overflow: auto;
|
|
99
|
+
overscroll-behavior-inline: contain;
|
|
100
|
+
scrollbar-width: thin;
|
|
101
|
+
}
|
|
102
|
+
div[data-size='fill'] {
|
|
103
|
+
width: 100%;
|
|
104
|
+
}
|
|
105
|
+
div[data-orientation='vertical'] {
|
|
106
|
+
--tabs-indicator-block-start: 0;
|
|
107
|
+
--tabs-indicator-inline-start: auto;
|
|
108
|
+
--tabs-indicator-width: 2px;
|
|
109
|
+
--tabs-indicator-height: auto;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
max-height: 100%;
|
|
112
|
+
min-height: 0;
|
|
113
|
+
border-block-end: 0;
|
|
114
|
+
border-inline-end: 1px solid color-mix(in srgb, var(--color-foreground) 12%, transparent);
|
|
115
|
+
overflow-x: hidden;
|
|
116
|
+
overflow-y: auto;
|
|
117
|
+
overscroll-behavior-block: contain;
|
|
118
|
+
}
|
|
119
|
+
@media (forced-colors: active) {
|
|
120
|
+
div {
|
|
121
|
+
border-color: ButtonBorder;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Dimension, IconName } from '../types.ts';
|
|
2
|
+
import type { TransitionConfig } from 'svelte/transition';
|
|
3
|
+
|
|
4
|
+
/** An indicator transition, including deferred Svelte transition configurations. */
|
|
5
|
+
export type TabTransition = (node: HTMLElement) => TransitionConfig | (() => TransitionConfig);
|
|
6
|
+
|
|
7
|
+
/** Data for a tab in the strip. */
|
|
8
|
+
export interface TabItemDataProps {
|
|
9
|
+
/** Unique tab ID used for selection. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Accessible name and tab text; small screens show only the selected horizontal label. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Material Symbols icon displayed beside the label. */
|
|
14
|
+
icon: IconName;
|
|
15
|
+
/** Prevent selection of this tab. */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Props for a rendered tab item, including its selection state. */
|
|
20
|
+
export type TabItemProps = TabItemDataProps & {
|
|
21
|
+
/** Whether this item is selected, derived from the strip's value. */
|
|
22
|
+
selected?: boolean;
|
|
23
|
+
/** Use filled glyphs for all tab icons. */
|
|
24
|
+
filled?: boolean;
|
|
25
|
+
/** Select this item when activated. */
|
|
26
|
+
onSelect: () => void;
|
|
27
|
+
/** Animate the selected indicator into this item. */
|
|
28
|
+
transitionIn?: TabTransition;
|
|
29
|
+
/** Animate the selected indicator out of this item. */
|
|
30
|
+
transitionOut?: TabTransition;
|
|
31
|
+
} & OrientationProps;
|
|
32
|
+
|
|
33
|
+
type OrientationProps =
|
|
34
|
+
| {
|
|
35
|
+
orientation?: 'horizontal';
|
|
36
|
+
/** Fit content and scroll on overflow, or fill the parent and truncate labels. */
|
|
37
|
+
size?: Dimension;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
orientation: 'vertical';
|
|
41
|
+
size?: never;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** A horizontal or vertical tab strip; the consumer owns the content. */
|
|
45
|
+
export type TabsProps = {
|
|
46
|
+
/** Tabs in display order. */
|
|
47
|
+
items: readonly TabItemDataProps[];
|
|
48
|
+
/** ID of the selected tab. */
|
|
49
|
+
value?: string;
|
|
50
|
+
/** Called with the tab ID when user interaction changes selection. */
|
|
51
|
+
onChange?: (id: string) => void;
|
|
52
|
+
/** Accessible name for the tab list. */
|
|
53
|
+
label?: string;
|
|
54
|
+
} & OrientationProps &
|
|
55
|
+
Pick<TabItemProps, 'filled' | 'transitionIn' | 'transitionOut'>;
|
package/src/lib/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type Color = 'primary' | 'secondary' | 'foreground' | 'background' | 'mut
|
|
|
6
6
|
export type Orientation = 'horizontal' | 'vertical';
|
|
7
7
|
export type Dimension = 'fit' | 'fill';
|
|
8
8
|
|
|
9
|
+
export type { TabItemProps, TabItemDataProps, TabsProps, TabTransition } from './tabs/types.js';
|
|
9
10
|
export type { ButtonProps } from './button/types.js';
|
|
10
11
|
export type {
|
|
11
12
|
ComboboxProps,
|