bref-ui 2.1.3 → 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/checkbox/checkbox.svelte +9 -1
- package/dist/combobox/input.svelte.d.ts +1 -1
- package/dist/dialog/header.svelte +14 -4
- 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/src/lib/checkbox/checkbox.svelte +9 -1
- package/src/lib/dialog/header.svelte +14 -4
- 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,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { fly } from 'svelte/transition';
|
|
3
3
|
import { elasticOut } from 'svelte/easing';
|
|
4
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
4
5
|
import Icon from '../icon/icon.svelte';
|
|
5
6
|
import type { CheckboxProps } from './types.js';
|
|
6
7
|
|
|
@@ -10,7 +11,14 @@
|
|
|
10
11
|
<div data-checkbox data-size={size}>
|
|
11
12
|
<input {...attributes} type="checkbox" bind:checked />
|
|
12
13
|
{#if checked}
|
|
13
|
-
<span
|
|
14
|
+
<span
|
|
15
|
+
aria-hidden="true"
|
|
16
|
+
transition:fly={{
|
|
17
|
+
duration: prefersReducedMotion.current ? 0 : 350,
|
|
18
|
+
y: prefersReducedMotion.current ? 0 : -6,
|
|
19
|
+
easing: elasticOut
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
14
22
|
<Icon name="check" style="font-variation-settings: 'wght' 500" />
|
|
15
23
|
</span>
|
|
16
24
|
{/if}
|
|
@@ -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;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Button from '../button/button.svelte';
|
|
3
3
|
import Icon from '../icon/icon.svelte';
|
|
4
|
+
import { fly } from 'svelte/transition';
|
|
5
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
4
6
|
import type { DialogHeaderProps } from './types.js';
|
|
5
7
|
|
|
6
8
|
let { title, description, icon, dismissible, id, onDismiss }: DialogHeaderProps = $props();
|
|
@@ -8,10 +10,18 @@
|
|
|
8
10
|
|
|
9
11
|
<header>
|
|
10
12
|
<div>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
{#key `${icon?.name ?? ''}:${title}`}
|
|
14
|
+
<div
|
|
15
|
+
data-title
|
|
16
|
+
in:fly={{
|
|
17
|
+
y: prefersReducedMotion.current ? 0 : 8,
|
|
18
|
+
duration: prefersReducedMotion.current ? 0 : 180
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
{#if icon}<Icon {...icon} size="medium" color="foreground" label={undefined} />{/if}
|
|
22
|
+
<h2 id={`${id}-title`}>{title}</h2>
|
|
23
|
+
</div>
|
|
24
|
+
{/key}
|
|
15
25
|
{#if description}<p id={`${id}-description`}>{description}</p>{/if}
|
|
16
26
|
</div>
|
|
17
27
|
{#if dismissible}
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { fly } from 'svelte/transition';
|
|
3
3
|
import { elasticOut } from 'svelte/easing';
|
|
4
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
4
5
|
import Icon from '../icon/icon.svelte';
|
|
5
6
|
import type { CheckboxProps } from './types.js';
|
|
6
7
|
|
|
@@ -10,7 +11,14 @@
|
|
|
10
11
|
<div data-checkbox data-size={size}>
|
|
11
12
|
<input {...attributes} type="checkbox" bind:checked />
|
|
12
13
|
{#if checked}
|
|
13
|
-
<span
|
|
14
|
+
<span
|
|
15
|
+
aria-hidden="true"
|
|
16
|
+
transition:fly={{
|
|
17
|
+
duration: prefersReducedMotion.current ? 0 : 350,
|
|
18
|
+
y: prefersReducedMotion.current ? 0 : -6,
|
|
19
|
+
easing: elasticOut
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
14
22
|
<Icon name="check" style="font-variation-settings: 'wght' 500" />
|
|
15
23
|
</span>
|
|
16
24
|
{/if}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Button from '../button/button.svelte';
|
|
3
3
|
import Icon from '../icon/icon.svelte';
|
|
4
|
+
import { fly } from 'svelte/transition';
|
|
5
|
+
import { prefersReducedMotion } from 'svelte/motion';
|
|
4
6
|
import type { DialogHeaderProps } from './types.js';
|
|
5
7
|
|
|
6
8
|
let { title, description, icon, dismissible, id, onDismiss }: DialogHeaderProps = $props();
|
|
@@ -8,10 +10,18 @@
|
|
|
8
10
|
|
|
9
11
|
<header>
|
|
10
12
|
<div>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
{#key `${icon?.name ?? ''}:${title}`}
|
|
14
|
+
<div
|
|
15
|
+
data-title
|
|
16
|
+
in:fly={{
|
|
17
|
+
y: prefersReducedMotion.current ? 0 : 8,
|
|
18
|
+
duration: prefersReducedMotion.current ? 0 : 180
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
{#if icon}<Icon {...icon} size="medium" color="foreground" label={undefined} />{/if}
|
|
22
|
+
<h2 id={`${id}-title`}>{title}</h2>
|
|
23
|
+
</div>
|
|
24
|
+
{/key}
|
|
15
25
|
{#if description}<p id={`${id}-description`}>{description}</p>{/if}
|
|
16
26
|
</div>
|
|
17
27
|
{#if dismissible}
|
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,
|