zabi-components 8.1.0 → 9.0.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/CHANGELOG.md +1011 -0
- package/README.md +1 -1
- package/THEME.md +18 -5
- package/THEMING.md +308 -74
- package/dist/atoms/ActionPanel.svelte +1 -1
- package/dist/atoms/Avatar.svelte +19 -3
- package/dist/atoms/Avatar.svelte.d.ts +12 -0
- package/dist/atoms/Badge.svelte +37 -8
- package/dist/atoms/Badge.svelte.d.ts +6 -0
- package/dist/atoms/Block.svelte +74 -0
- package/dist/atoms/Block.svelte.d.ts +29 -0
- package/dist/atoms/Button.svelte +52 -15
- package/dist/atoms/Button.svelte.d.ts +3 -2
- package/dist/atoms/Card.svelte +181 -25
- package/dist/atoms/Card.svelte.d.ts +55 -3
- package/dist/atoms/Checkbox.svelte +33 -0
- package/dist/atoms/Checkbox.svelte.d.ts +27 -0
- package/dist/atoms/Chip.svelte +351 -0
- package/dist/atoms/Chip.svelte.d.ts +57 -0
- package/dist/atoms/CodeField.svelte +385 -0
- package/dist/atoms/CodeField.svelte.d.ts +61 -0
- package/dist/atoms/ColorPicker.svelte +1 -0
- package/dist/atoms/DateField.svelte +41 -3
- package/dist/atoms/DateField.svelte.d.ts +31 -3
- package/dist/atoms/FloatingActionButton.svelte +5 -3
- package/dist/atoms/FloatingActionButton.svelte.d.ts +1 -1
- package/dist/atoms/Heading.svelte +27 -1
- package/dist/atoms/Heading.svelte.d.ts +7 -0
- package/dist/atoms/IconButton.svelte +81 -9
- package/dist/atoms/IconButton.svelte.d.ts +13 -0
- package/dist/atoms/Input.svelte +10 -5
- package/dist/atoms/Input.svelte.d.ts +3 -2
- package/dist/atoms/List.svelte +3 -2
- package/dist/atoms/ListItem.svelte +31 -7
- package/dist/atoms/ListItem.svelte.d.ts +6 -0
- package/dist/atoms/PickerField.svelte +239 -0
- package/dist/atoms/PickerField.svelte.d.ts +67 -0
- package/dist/atoms/Progress.svelte +78 -17
- package/dist/atoms/Progress.svelte.d.ts +11 -1
- package/dist/atoms/Radio.svelte +32 -0
- package/dist/atoms/Radio.svelte.d.ts +26 -0
- package/dist/atoms/Rating.svelte +132 -6
- package/dist/atoms/Rating.svelte.d.ts +24 -3
- package/dist/atoms/Select.svelte +24 -26
- package/dist/atoms/Select.svelte.d.ts +12 -0
- package/dist/atoms/SelectionControl.svelte +99 -6
- package/dist/atoms/SelectionControl.svelte.d.ts +25 -0
- package/dist/atoms/Skeleton.svelte +114 -18
- package/dist/atoms/Skeleton.svelte.d.ts +30 -3
- package/dist/atoms/SkeletonGroup.svelte +43 -0
- package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
- package/dist/atoms/Slider.svelte +35 -0
- package/dist/atoms/Sparkline.svelte +109 -0
- package/dist/atoms/Sparkline.svelte.d.ts +27 -0
- package/dist/atoms/SpeechBubble.svelte +187 -0
- package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
- package/dist/atoms/Stat.svelte +72 -0
- package/dist/atoms/Stat.svelte.d.ts +19 -0
- package/dist/atoms/TemporalField.svelte +342 -10
- package/dist/atoms/TemporalField.svelte.d.ts +20 -4
- package/dist/atoms/Textarea.svelte +100 -5
- package/dist/atoms/Textarea.svelte.d.ts +21 -0
- package/dist/atoms/TimeColumn.svelte +150 -0
- package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
- package/dist/atoms/TimeField.svelte +46 -3
- package/dist/atoms/TimeField.svelte.d.ts +34 -3
- package/dist/atoms/Toast.svelte +6 -5
- package/dist/atoms/Toggle.svelte +61 -10
- package/dist/atoms/Toggle.svelte.d.ts +15 -0
- package/dist/atoms/Tooltip.svelte +20 -1
- package/dist/atoms/Tooltip.svelte.d.ts +7 -0
- package/dist/atoms/index.d.ts +21 -0
- package/dist/atoms/index.js +17 -0
- package/dist/atoms/selection-control.styles.d.ts +15 -4
- package/dist/atoms/selection-control.styles.js +16 -5
- package/dist/components/atoms/index.d.ts +21 -0
- package/dist/components/atoms/index.d.ts.map +1 -1
- package/dist/components/atoms/selection-control.styles.d.ts +15 -4
- package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
- package/dist/components/index.d.ts +27 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/map.d.ts +11 -0
- package/dist/components/map.d.ts.map +1 -0
- package/dist/components/molecules/index.d.ts +7 -2
- package/dist/components/molecules/index.d.ts.map +1 -1
- package/dist/components/molecules/toast-store.d.ts +30 -0
- package/dist/components/molecules/toast-store.d.ts.map +1 -1
- package/dist/components/organisms/index.d.ts +6 -1
- package/dist/components/organisms/index.d.ts.map +1 -1
- package/dist/components/types/variants.d.ts +12 -3
- package/dist/components/types/variants.d.ts.map +1 -1
- package/dist/components/util/app-shell.d.ts +55 -4
- package/dist/components/util/app-shell.d.ts.map +1 -1
- package/dist/components/util/avatar.d.ts +3 -0
- package/dist/components/util/avatar.d.ts.map +1 -1
- package/dist/components/util/bottom-tab-bar.d.ts +6 -0
- package/dist/components/util/bottom-tab-bar.d.ts.map +1 -1
- package/dist/components/util/chart.d.ts +140 -0
- package/dist/components/util/chart.d.ts.map +1 -0
- package/dist/components/util/chip.d.ts +66 -0
- package/dist/components/util/chip.d.ts.map +1 -0
- package/dist/components/util/cn.d.ts.map +1 -1
- package/dist/components/util/code-field.d.ts +63 -0
- package/dist/components/util/code-field.d.ts.map +1 -0
- package/dist/components/util/collapsible.d.ts +6 -0
- package/dist/components/util/collapsible.d.ts.map +1 -1
- package/dist/components/util/display-voice.d.ts +10 -0
- package/dist/components/util/display-voice.d.ts.map +1 -0
- package/dist/components/util/field-control.d.ts +30 -0
- package/dist/components/util/field-control.d.ts.map +1 -0
- package/dist/components/util/field.d.ts +6 -0
- package/dist/components/util/field.d.ts.map +1 -1
- package/dist/components/util/focus-utils.d.ts +2 -0
- package/dist/components/util/focus-utils.d.ts.map +1 -1
- package/dist/components/util/map-view.d.ts +144 -0
- package/dist/components/util/map-view.d.ts.map +1 -0
- package/dist/components/util/photo.d.ts +19 -0
- package/dist/components/util/photo.d.ts.map +1 -1
- package/dist/components/util/progress.d.ts +19 -0
- package/dist/components/util/progress.d.ts.map +1 -0
- package/dist/components/util/scroll-edge.d.ts +33 -0
- package/dist/components/util/scroll-edge.d.ts.map +1 -0
- package/dist/components/util/sheet-drag.d.ts +25 -1
- package/dist/components/util/sheet-drag.d.ts.map +1 -1
- package/dist/components/util/skeleton.d.ts +15 -0
- package/dist/components/util/skeleton.d.ts.map +1 -0
- package/dist/components/util/sliding-indicator.d.ts +25 -0
- package/dist/components/util/sliding-indicator.d.ts.map +1 -0
- package/dist/components/util/temporal-field.d.ts +89 -0
- package/dist/components/util/temporal-field.d.ts.map +1 -0
- package/dist/components/util/toaster.d.ts +40 -0
- package/dist/components/util/toaster.d.ts.map +1 -1
- package/dist/components/util/tone-surface.d.ts +34 -0
- package/dist/components/util/tone-surface.d.ts.map +1 -0
- package/dist/components/util/top-navbar.d.ts +26 -2
- package/dist/components/util/top-navbar.d.ts.map +1 -1
- package/dist/components/util/zabi-strings.d.ts +9 -0
- package/dist/components/util/zabi-strings.d.ts.map +1 -1
- package/dist/create-theme/cli.js +6 -2
- package/dist/create-theme/index.d.ts +17 -3
- package/dist/create-theme/index.js +252 -35
- package/dist/create-theme/lib/resolve.js +97 -0
- package/dist/create-theme/theme-data.js +1618 -90
- package/dist/index.d.ts +27 -3
- package/dist/index.js +18 -0
- package/dist/map.d.ts +10 -0
- package/dist/map.js +9 -0
- package/dist/molecules/Alert.svelte +31 -12
- package/dist/molecules/Alert.svelte.d.ts +12 -1
- package/dist/molecules/AppBar.svelte +287 -28
- package/dist/molecules/AppBar.svelte.d.ts +62 -0
- package/dist/molecules/AvatarGroup.svelte +1 -1
- package/dist/molecules/BarChart.svelte +381 -0
- package/dist/molecules/BarChart.svelte.d.ts +45 -0
- package/dist/molecules/BottomSheet.svelte +88 -20
- package/dist/molecules/BottomSheet.svelte.d.ts +17 -1
- package/dist/molecules/BottomTabBar.svelte +134 -28
- package/dist/molecules/BottomTabBar.svelte.d.ts +36 -2
- package/dist/molecules/Calendar.svelte.d.ts +1 -1
- package/dist/molecules/ChipGroup.svelte +325 -0
- package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
- package/dist/molecules/Collapsible.svelte +158 -54
- package/dist/molecules/Collapsible.svelte.d.ts +23 -6
- package/dist/molecules/CollapsibleGroup.svelte +16 -6
- package/dist/molecules/ConfirmDialog.svelte +5 -1
- package/dist/molecules/ConfirmDialog.svelte.d.ts +3 -0
- package/dist/molecules/Drawer.svelte +33 -12
- package/dist/molecules/Dropdown.svelte +9 -1
- package/dist/molecules/Dropdown.svelte.d.ts +2 -0
- package/dist/molecules/DropdownItem.svelte +1 -1
- package/dist/molecules/ImageUpload.svelte +230 -120
- package/dist/molecules/ImageUpload.svelte.d.ts +38 -3
- package/dist/molecules/LineChart.svelte +459 -0
- package/dist/molecules/LineChart.svelte.d.ts +53 -0
- package/dist/molecules/MapView.svelte +565 -0
- package/dist/molecules/MapView.svelte.d.ts +88 -0
- package/dist/molecules/Modal.svelte +11 -6
- package/dist/molecules/PhotoGrid.svelte +100 -10
- package/dist/molecules/PhotoGrid.svelte.d.ts +11 -0
- package/dist/molecules/PhotoViewer.svelte +121 -24
- package/dist/molecules/PhotoViewer.svelte.d.ts +18 -0
- package/dist/molecules/PieChart.svelte +421 -0
- package/dist/molecules/PieChart.svelte.d.ts +51 -0
- package/dist/molecules/RadioGroup.svelte +20 -4
- package/dist/molecules/RadioGroup.svelte.d.ts +5 -0
- package/dist/molecules/SegmentedControl.svelte +136 -3
- package/dist/molecules/SegmentedControl.svelte.d.ts +6 -0
- package/dist/molecules/SidebarFooter.svelte +1 -1
- package/dist/molecules/SidebarNavSection.svelte +1 -1
- package/dist/molecules/SortableList.svelte +1 -0
- package/dist/molecules/Stepper.svelte +21 -7
- package/dist/molecules/StickyActionBar.svelte +67 -8
- package/dist/molecules/StickyActionBar.svelte.d.ts +14 -1
- package/dist/molecules/Tabs.svelte +79 -2
- package/dist/molecules/Toaster.svelte +171 -7
- package/dist/molecules/Toaster.svelte.d.ts +60 -2
- package/dist/molecules/ToasterToast.svelte +292 -60
- package/dist/molecules/ToasterToast.svelte.d.ts +6 -0
- package/dist/molecules/UnsavedChangesBar.svelte +13 -2
- package/dist/molecules/index.d.ts +7 -2
- package/dist/molecules/index.js +4 -0
- package/dist/molecules/toast-store.d.ts +30 -0
- package/dist/molecules/toast-store.js +4 -1
- package/dist/organisms/AppNavigation.svelte +440 -0
- package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
- package/dist/organisms/AppShell.svelte +539 -39
- package/dist/organisms/AppShell.svelte.d.ts +102 -6
- package/dist/organisms/AppShellHeaderPart.svelte +163 -0
- package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
- package/dist/organisms/SidebarNavigation.svelte +23 -36
- package/dist/organisms/SidebarPanel.svelte +10 -14
- package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
- package/dist/organisms/SidebarShell.svelte +2 -3
- package/dist/organisms/TopNavbar.svelte +117 -28
- package/dist/organisms/TopNavbar.svelte.d.ts +22 -1
- package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
- package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
- package/dist/organisms/index.d.ts +6 -1
- package/dist/organisms/index.js +4 -0
- package/dist/types/components.d.ts +17 -1
- package/dist/types/components.d.ts.map +1 -1
- package/dist/types/index.d.ts +497 -10
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/variants.ts +14 -3
- package/dist/util/app-shell.d.ts +55 -4
- package/dist/util/app-shell.js +32 -7
- package/dist/util/avatar.d.ts +3 -0
- package/dist/util/bottom-tab-bar.d.ts +6 -0
- package/dist/util/chart.d.ts +139 -0
- package/dist/util/chart.js +305 -0
- package/dist/util/chip.d.ts +65 -0
- package/dist/util/chip.js +58 -0
- package/dist/util/cn.js +28 -2
- package/dist/util/code-field.d.ts +62 -0
- package/dist/util/code-field.js +112 -0
- package/dist/util/collapsible.d.ts +6 -0
- package/dist/util/display-voice.d.ts +9 -0
- package/dist/util/display-voice.js +9 -0
- package/dist/util/field-control.d.ts +29 -0
- package/dist/util/field-control.js +32 -0
- package/dist/util/field.d.ts +6 -0
- package/dist/util/focus-utils.d.ts +2 -0
- package/dist/util/focus-utils.js +4 -0
- package/dist/util/map-view.d.ts +143 -0
- package/dist/util/map-view.js +121 -0
- package/dist/util/photo.d.ts +19 -0
- package/dist/util/photo.js +3 -0
- package/dist/util/progress.d.ts +18 -0
- package/dist/util/progress.js +17 -0
- package/dist/util/scroll-edge.d.ts +32 -0
- package/dist/util/scroll-edge.js +87 -0
- package/dist/util/sheet-drag.d.ts +25 -1
- package/dist/util/sheet-drag.js +71 -1
- package/dist/util/skeleton.d.ts +14 -0
- package/dist/util/skeleton.js +22 -0
- package/dist/util/sliding-indicator.d.ts +24 -0
- package/dist/util/sliding-indicator.js +44 -0
- package/dist/util/temporal-field.d.ts +88 -0
- package/dist/util/temporal-field.js +136 -0
- package/dist/util/toaster.d.ts +40 -0
- package/dist/util/toaster.js +55 -0
- package/dist/util/tone-surface.d.ts +33 -0
- package/dist/util/tone-surface.js +35 -0
- package/dist/util/top-navbar.d.ts +26 -2
- package/dist/util/top-navbar.js +23 -0
- package/dist/util/zabi-strings.d.ts +9 -0
- package/dist/zabi-components-colors.css +9 -3
- package/dist/zabi-components-theme-dark-only.css +474 -164
- package/dist/zabi-components-theme-dark.css +474 -164
- package/dist/zabi-components-theme-only.css +933 -99
- package/dist/zabi-components-theme.css +933 -99
- package/dist/zabi-components.css +2275 -321
- package/package.json +11 -1
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
import { isRtl, radioKeyIndex } from "../util/radio-keys.js";
|
|
7
7
|
import type { SegmentedControlOption } from "../util/segmented-control.js";
|
|
8
8
|
import { generateId } from "../util/ssr-safe.js";
|
|
9
|
+
import {
|
|
10
|
+
afterFrame,
|
|
11
|
+
measureIndicator,
|
|
12
|
+
observeSizes,
|
|
13
|
+
type IndicatorBox,
|
|
14
|
+
} from "../util/sliding-indicator.js";
|
|
9
15
|
|
|
10
16
|
/** Other attributes (`data-*`, `id`, ...) land on the host element. */
|
|
11
17
|
type Props = Omit<
|
|
@@ -34,6 +40,12 @@
|
|
|
34
40
|
* form reset leaves it alone.
|
|
35
41
|
*/
|
|
36
42
|
name?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The look of the selected segment. `neutral`: a card-coloured thumb with
|
|
45
|
+
* the headline colour on a tinted track, which does not compete with the
|
|
46
|
+
* screen's main button. `primary`: a solid primary fill, as in 8.1.
|
|
47
|
+
*/
|
|
48
|
+
tone?: "neutral" | "primary";
|
|
37
49
|
disabled?: boolean;
|
|
38
50
|
/** Called with the new value when the user picks another segment. */
|
|
39
51
|
onchange?: (value: string) => void;
|
|
@@ -49,6 +61,7 @@
|
|
|
49
61
|
size = "md",
|
|
50
62
|
fullWidth = true,
|
|
51
63
|
name = "",
|
|
64
|
+
tone = "neutral",
|
|
52
65
|
disabled = false,
|
|
53
66
|
onchange,
|
|
54
67
|
...restProps
|
|
@@ -78,6 +91,43 @@
|
|
|
78
91
|
options.filter((option) => !disabled && !option.disabled),
|
|
79
92
|
);
|
|
80
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The sliding indicator. Until a box has been measured (on the server,
|
|
96
|
+
* before hydration, with no layout) it is null and the checked segment draws
|
|
97
|
+
* its own selected look; with a box, the host is marked ready and that look
|
|
98
|
+
* moves to the indicator, which slides between segments.
|
|
99
|
+
*/
|
|
100
|
+
let host = $state<HTMLDivElement | null>(null);
|
|
101
|
+
let indicator = $state<IndicatorBox | null>(null);
|
|
102
|
+
/** Off for the first placement, so the indicator appears in place and does not fly in. */
|
|
103
|
+
let indicatorMotion = $state(false);
|
|
104
|
+
|
|
105
|
+
const faceOf = (input: HTMLInputElement | undefined) =>
|
|
106
|
+
input?.nextElementSibling as HTMLElement | null | undefined;
|
|
107
|
+
|
|
108
|
+
function measure() {
|
|
109
|
+
const face = value === undefined ? undefined : faceOf(inputs[value]);
|
|
110
|
+
indicator = host && face ? measureIndicator(host, face) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// After the DOM has followed a change of selection, options, size or look.
|
|
114
|
+
$effect(() => {
|
|
115
|
+
void [options, size, fullWidth, tone, host, value];
|
|
116
|
+
measure();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// The segments wrap and resize with zoomed text, a loaded font or a narrower container.
|
|
120
|
+
$effect(() => {
|
|
121
|
+
void options;
|
|
122
|
+
if (!host) return;
|
|
123
|
+
return observeSizes([host, ...Object.values(inputs).map(faceOf)], measure);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
$effect(() => {
|
|
127
|
+
if (!indicator || indicatorMotion) return;
|
|
128
|
+
return afterFrame(() => (indicatorMotion = true));
|
|
129
|
+
});
|
|
130
|
+
|
|
81
131
|
/** The segment that takes Tab: the selected one, or the first that can be chosen. */
|
|
82
132
|
const tabStop = $derived(
|
|
83
133
|
enabled.find((option) => option.value === value)?.value ?? enabled[0]?.value,
|
|
@@ -128,6 +178,10 @@ one changes nothing, and the value goes with a form. -->
|
|
|
128
178
|
<div
|
|
129
179
|
class={cn("segmented", fullWidth ? "w-full" : "", className)}
|
|
130
180
|
data-size={size}
|
|
181
|
+
data-tone={tone}
|
|
182
|
+
data-indicator={indicator ? "ready" : undefined}
|
|
183
|
+
data-indicator-motion={indicator && indicatorMotion ? "" : undefined}
|
|
184
|
+
bind:this={host}
|
|
131
185
|
data-full-width={fullWidth ? "" : undefined}
|
|
132
186
|
role="radiogroup"
|
|
133
187
|
aria-label={ariaLabelledby ? undefined : label || ariaLabel}
|
|
@@ -135,6 +189,17 @@ one changes nothing, and the value goes with a form. -->
|
|
|
135
189
|
aria-disabled={disabled ? "true" : undefined}
|
|
136
190
|
{...restProps}
|
|
137
191
|
>
|
|
192
|
+
{#if indicator}
|
|
193
|
+
<!-- Under the segments (it comes first and they are positioned), and never in their way. -->
|
|
194
|
+
<span
|
|
195
|
+
class="segment-indicator"
|
|
196
|
+
aria-hidden="true"
|
|
197
|
+
data-disabled={disabled || options.find((option) => option.value === value)?.disabled
|
|
198
|
+
? ""
|
|
199
|
+
: undefined}
|
|
200
|
+
style="transform: translate({indicator.x}px, {indicator.y}px); width: {indicator.width}px; height: {indicator.height}px;"
|
|
201
|
+
></span>
|
|
202
|
+
{/if}
|
|
138
203
|
{#each options as option (option.value)}
|
|
139
204
|
{@const optionDisabled = disabled || !!option.disabled}
|
|
140
205
|
{@const Icon = option.icon}
|
|
@@ -184,6 +249,8 @@ one changes nothing, and the value goes with a form. -->
|
|
|
184
249
|
border: 1px solid var(--color-border);
|
|
185
250
|
border-radius: var(--radius-control);
|
|
186
251
|
background: var(--color-action-secondary);
|
|
252
|
+
/* The sliding indicator is placed against the padding box. */
|
|
253
|
+
position: relative;
|
|
187
254
|
}
|
|
188
255
|
|
|
189
256
|
.segmented:not([data-full-width]) {
|
|
@@ -241,8 +308,8 @@ one changes nothing, and the value goes with a form. -->
|
|
|
241
308
|
line-height: 1.25;
|
|
242
309
|
text-align: center;
|
|
243
310
|
transition:
|
|
244
|
-
background-color
|
|
245
|
-
color
|
|
311
|
+
background-color var(--duration-base),
|
|
312
|
+
color var(--duration-base);
|
|
246
313
|
}
|
|
247
314
|
|
|
248
315
|
/* A long label wraps onto a second line, and breaks inside a word only when a word alone does not fit. */
|
|
@@ -263,9 +330,64 @@ one changes nothing, and the value goes with a form. -->
|
|
|
263
330
|
background: var(--color-surface-active);
|
|
264
331
|
}
|
|
265
332
|
|
|
333
|
+
/*
|
|
334
|
+
* The selected look, drawn by the segment itself: before the page hydrates,
|
|
335
|
+
* and wherever the indicator cannot be measured. Neutral is a thumb: the
|
|
336
|
+
* card colour with a soft shadow and an edge (the edge, held to 3:1 against
|
|
337
|
+
* the track, is what tells it from the others; the fill is only a step).
|
|
338
|
+
*/
|
|
266
339
|
input:checked + .segment-face {
|
|
340
|
+
background: var(--color-segment-thumb);
|
|
341
|
+
color: var(--color-segment-thumb-text);
|
|
342
|
+
box-shadow:
|
|
343
|
+
var(--shadow-sm),
|
|
344
|
+
0 0 0 1px var(--color-segment-thumb-border);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
:where(.segmented[data-tone="primary"]) input:checked + .segment-face {
|
|
267
348
|
background: var(--color-action-primary);
|
|
268
349
|
color: var(--color-action-primary-text);
|
|
350
|
+
box-shadow: none;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* With an indicator the segment keeps only its label colour. */
|
|
354
|
+
@media (forced-colors: none) {
|
|
355
|
+
:where(.segmented[data-indicator="ready"]) input:checked + .segment-face {
|
|
356
|
+
background: transparent;
|
|
357
|
+
box-shadow: none;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.segment-indicator {
|
|
362
|
+
position: absolute;
|
|
363
|
+
top: 0;
|
|
364
|
+
left: 0;
|
|
365
|
+
pointer-events: none;
|
|
366
|
+
border-radius: calc(var(--radius-control) - var(--zabi-segment-inset) - 1px);
|
|
367
|
+
background: var(--color-segment-thumb);
|
|
368
|
+
box-shadow:
|
|
369
|
+
var(--shadow-sm),
|
|
370
|
+
0 0 0 1px var(--color-segment-thumb-border);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.segmented[data-tone="primary"] .segment-indicator {
|
|
374
|
+
background: var(--color-action-primary);
|
|
375
|
+
box-shadow: none;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* Only once the first placement is on screen: the thumb then slides and resizes. */
|
|
379
|
+
.segmented[data-indicator-motion] .segment-indicator {
|
|
380
|
+
transition:
|
|
381
|
+
transform var(--duration-moderate) var(--ease-spring),
|
|
382
|
+
width var(--duration-moderate) var(--ease-spring),
|
|
383
|
+
height var(--duration-moderate) var(--ease-spring);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* Forced colours keep the segment's own Highlight look below. */
|
|
387
|
+
@media (forced-colors: active) {
|
|
388
|
+
.segment-indicator {
|
|
389
|
+
display: none;
|
|
390
|
+
}
|
|
269
391
|
}
|
|
270
392
|
|
|
271
393
|
/* Same geometry as `.focus-ring`: a 2px gap, then a 2px ring. */
|
|
@@ -283,6 +405,16 @@ one changes nothing, and the value goes with a form. -->
|
|
|
283
405
|
opacity: 0.5;
|
|
284
406
|
}
|
|
285
407
|
|
|
408
|
+
/* A disabled neutral thumb keeps its edge at full strength, so it is still seen; only its label dims. */
|
|
409
|
+
:where(.segmented[data-tone="neutral"]) input:checked:disabled + .segment-face {
|
|
410
|
+
opacity: 1;
|
|
411
|
+
color: color-mix(in srgb, var(--color-segment-thumb-text) 50%, transparent);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.segmented[data-tone="primary"] .segment-indicator[data-disabled] {
|
|
415
|
+
opacity: 0.5;
|
|
416
|
+
}
|
|
417
|
+
|
|
286
418
|
/*
|
|
287
419
|
* Forced colours paint every background with the canvas colour and drop
|
|
288
420
|
* every box-shadow, which would leave the selected segment looking like
|
|
@@ -316,7 +448,8 @@ one changes nothing, and the value goes with a form. -->
|
|
|
316
448
|
}
|
|
317
449
|
|
|
318
450
|
@media (prefers-reduced-motion: reduce) {
|
|
319
|
-
.segment-face
|
|
451
|
+
.segment-face,
|
|
452
|
+
.segmented[data-indicator-motion] .segment-indicator {
|
|
320
453
|
transition: none;
|
|
321
454
|
}
|
|
322
455
|
}
|
|
@@ -25,6 +25,12 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange" | "aria-l
|
|
|
25
25
|
* form reset leaves it alone.
|
|
26
26
|
*/
|
|
27
27
|
name?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The look of the selected segment. `neutral`: a card-coloured thumb with
|
|
30
|
+
* the headline colour on a tinted track, which does not compete with the
|
|
31
|
+
* screen's main button. `primary`: a solid primary fill, as in 8.1.
|
|
32
|
+
*/
|
|
33
|
+
tone?: "neutral" | "primary";
|
|
28
34
|
disabled?: boolean;
|
|
29
35
|
/** Called with the new value when the user picks another segment. */
|
|
30
36
|
onchange?: (value: string) => void;
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
{#if showFooter}
|
|
92
92
|
<div class="w-full shrink-0" data-sidebar-flyout-root>
|
|
93
93
|
<footer
|
|
94
|
-
class={cn(`flex w-full shrink-0 flex-col gap-3 border-t border-border pt-3 pb-1 ${className}`)}
|
|
94
|
+
class={cn(`flex w-full shrink-0 flex-col gap-3 border-t border-border-weak pt-3 pb-1 ${className}`)}
|
|
95
95
|
aria-label={text.accountAndSettings}
|
|
96
96
|
>
|
|
97
97
|
{#if showPanelLauncher}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
<div class={cn(`flex w-full flex-col gap-2 ${className}`)}>
|
|
42
42
|
{#if showHeading && headingId}
|
|
43
43
|
<h2
|
|
44
|
-
class="px-0 text-xs font-
|
|
44
|
+
class="px-0 text-xs font-medium text-description"
|
|
45
45
|
id={headingId}
|
|
46
46
|
>
|
|
47
47
|
{trimmedTitle}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { HTMLAttributes } from "svelte/elements";
|
|
7
7
|
import type { SizeVariant } from "../types/variants.js";
|
|
8
8
|
import { cn } from "../util/cn.js";
|
|
9
|
+
import { inSkeletonGroup } from "../util/skeleton.js";
|
|
9
10
|
import {
|
|
10
11
|
STEPPER_STRINGS,
|
|
11
12
|
clampStep,
|
|
@@ -100,6 +101,9 @@
|
|
|
100
101
|
bodies[index]?.focus();
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/** Inside a `SkeletonGroup` the group is the one status; a second one in it would be a live region in a live region. */
|
|
105
|
+
const inGroup = inSkeletonGroup();
|
|
106
|
+
|
|
103
107
|
/** Read out once when the current step changes, and never for the first render. */
|
|
104
108
|
let announced = $state("");
|
|
105
109
|
let previous: number | undefined;
|
|
@@ -202,7 +206,9 @@
|
|
|
202
206
|
{/if}
|
|
203
207
|
</div>
|
|
204
208
|
</div>
|
|
205
|
-
|
|
209
|
+
{#if !inGroup}
|
|
210
|
+
<span class="sr-only" role="status" data-stepper-status>{announced}</span>
|
|
211
|
+
{/if}
|
|
206
212
|
</nav>
|
|
207
213
|
{/if}
|
|
208
214
|
|
|
@@ -375,8 +381,8 @@
|
|
|
375
381
|
}
|
|
376
382
|
|
|
377
383
|
/*
|
|
378
|
-
* Completed and current are filled, upcoming is
|
|
379
|
-
* of shape. Completed has a check where the others have their number, and
|
|
384
|
+
* Completed and current are filled in the brand colour, upcoming is the
|
|
385
|
+
* lighter track: a difference of shape too. Completed has a check where the others have their number, and
|
|
380
386
|
* the current one stands inside a ring of its own.
|
|
381
387
|
*/
|
|
382
388
|
.stepper-marker {
|
|
@@ -419,11 +425,19 @@
|
|
|
419
425
|
background-clip: content-box;
|
|
420
426
|
}
|
|
421
427
|
|
|
422
|
-
/*
|
|
423
|
-
|
|
428
|
+
/*
|
|
429
|
+
* An upcoming marker is the progress track: a filled pill in the bar
|
|
430
|
+
* layout, with the track's own edge. An app that themes the progress
|
|
431
|
+
* track (an edgeless one that reaches 3:1 on its own) gets the same here.
|
|
432
|
+
* `--zabi-stepper-upcoming-fill` and `--zabi-stepper-upcoming-edge` are
|
|
433
|
+
* declared nowhere, so any ancestor can set them. The 8.1 look, a hollow
|
|
434
|
+
* outline, is
|
|
435
|
+
* `--zabi-stepper-upcoming-fill: transparent;
|
|
436
|
+
* --zabi-stepper-upcoming-edge: var(--color-control-border);`
|
|
437
|
+
*/
|
|
424
438
|
.stepper-step[data-state="upcoming"] .stepper-marker {
|
|
425
|
-
border-color: var(--color-
|
|
426
|
-
background-color:
|
|
439
|
+
border-color: var(--zabi-stepper-upcoming-edge, var(--color-progress-track-border));
|
|
440
|
+
background-color: var(--zabi-stepper-upcoming-fill, var(--color-progress-track));
|
|
427
441
|
color: var(--color-description);
|
|
428
442
|
}
|
|
429
443
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
-
import { findScrollParent } from "../util/app-shell.js";
|
|
4
|
+
import { findScrollParent, getAppShell } from "../util/app-shell.js";
|
|
5
5
|
import { cn } from "../util/cn.js";
|
|
6
6
|
import { watchKeyboardInset } from "../util/keyboard-inset.js";
|
|
7
7
|
import { reserveScrollPaddingBottom } from "../util/scroll-padding.js";
|
|
8
|
+
import { resolveScrolledUnder, watchScrollEdge, type ScrollEdgeMode } from "../util/scroll-edge.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Keeps a form's main button on screen: at the bottom of whatever
|
|
@@ -47,7 +48,12 @@
|
|
|
47
48
|
*
|
|
48
49
|
* In an `AppShell`, a screen with a form is a task of its own: leave the
|
|
49
50
|
* tab bar out (no `footer`) and let this bar be the bottom of the screen.
|
|
50
|
-
* With a tab bar it sits directly above it
|
|
51
|
+
* With a tab bar it sits directly above it: the shell's footer lies over
|
|
52
|
+
* the scroller, and the bar sticks to the footer's top edge, not under it.
|
|
53
|
+
*
|
|
54
|
+
* The bar is flush with the page and page-coloured while the form ends
|
|
55
|
+
* where the bar is; once content is under it, it turns to frosted glass
|
|
56
|
+
* with a hairline above it.
|
|
51
57
|
*
|
|
52
58
|
* `UnsavedChangesBar` is the other bar at the bottom of a form. That one
|
|
53
59
|
* appears only while there is something to save, announces itself, and
|
|
@@ -60,6 +66,13 @@
|
|
|
60
66
|
* says what the buttons belong to; without it, it is a plain box.
|
|
61
67
|
*/
|
|
62
68
|
label?: string;
|
|
69
|
+
/**
|
|
70
|
+
* When the bar shows the glass and the hairline. `auto` follows the
|
|
71
|
+
* scroll position: flush at the end of the content, glass while
|
|
72
|
+
* content is under it. `always` is glass all the time, `never` flush
|
|
73
|
+
* all the time.
|
|
74
|
+
*/
|
|
75
|
+
scrollEdge?: ScrollEdgeMode;
|
|
63
76
|
class?: string;
|
|
64
77
|
/** Added after the offset and margin the bar sets while the keyboard is up. */
|
|
65
78
|
style?: string;
|
|
@@ -67,9 +80,35 @@
|
|
|
67
80
|
children?: Snippet;
|
|
68
81
|
};
|
|
69
82
|
|
|
70
|
-
let {
|
|
83
|
+
let {
|
|
84
|
+
label,
|
|
85
|
+
scrollEdge = "auto",
|
|
86
|
+
class: className = "",
|
|
87
|
+
style = "",
|
|
88
|
+
children,
|
|
89
|
+
...restProps
|
|
90
|
+
}: Props = $props();
|
|
71
91
|
|
|
72
92
|
let host: HTMLDivElement | undefined = $state();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* In a shell with a footer the footer lies over the bottom of the scroller
|
|
96
|
+
* by this much: the bar sticks above it, and the box it scrolls in
|
|
97
|
+
* extends under it.
|
|
98
|
+
*/
|
|
99
|
+
const shell = getAppShell();
|
|
100
|
+
const underFooter = $derived(shell?.footerOverlays === true);
|
|
101
|
+
const footerHeight = $derived(shell?.footerHeight ?? 0);
|
|
102
|
+
|
|
103
|
+
/** Content is under the bar: from the shell if there is one, else from its own scroller. */
|
|
104
|
+
let ownScrolled = $state(false);
|
|
105
|
+
$effect(() => {
|
|
106
|
+
if (shell || scrollEdge !== "auto" || !host) return;
|
|
107
|
+
return watchScrollEdge(findScrollParent(host), (edge) => (ownScrolled = edge.bottom));
|
|
108
|
+
});
|
|
109
|
+
const scrolledUnder = $derived(
|
|
110
|
+
resolveScrolledUnder(scrollEdge, shell ? shell.scrolledBottom : ownScrolled),
|
|
111
|
+
);
|
|
73
112
|
/** Height of the keyboard over the page, in px. 0 on the server and where the page shrinks by itself. */
|
|
74
113
|
let keyboardInset = $state(0);
|
|
75
114
|
/**
|
|
@@ -151,7 +190,9 @@
|
|
|
151
190
|
$effect(() => {
|
|
152
191
|
const bar = host;
|
|
153
192
|
if (!bar) return;
|
|
154
|
-
|
|
193
|
+
// The bar's own offset from the bottom of the box: what the keyboard
|
|
194
|
+
// lifts it by, or the footer it sits above, whichever is more.
|
|
195
|
+
const raised = Math.max(lift, footerHeight);
|
|
155
196
|
const container = findScrollParent(bar) ?? document.documentElement;
|
|
156
197
|
const reservation = reserveScrollPaddingBottom(container);
|
|
157
198
|
const reserve = () => {
|
|
@@ -168,6 +209,21 @@
|
|
|
168
209
|
});
|
|
169
210
|
|
|
170
211
|
const keyboardOpen = $derived(keyboardInset > 0);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where it sticks. On its own the box ends at the bottom and the bar is
|
|
215
|
+
* lifted by `lift`. Over a footer it sticks above the footer, and the
|
|
216
|
+
* keyboard lifts it only as far as it reaches past the footer: the box
|
|
217
|
+
* extends under the footer, so `lift` counts from the box's bottom.
|
|
218
|
+
*/
|
|
219
|
+
const offsetStyle = $derived.by(() => {
|
|
220
|
+
const overlay = "var(--app-shell-footer-overlay, 0px)";
|
|
221
|
+
if (!underFooter) return lift > 0 ? `bottom: ${lift}px; margin-bottom: ${lift}px;` : "";
|
|
222
|
+
if (lift > 0) {
|
|
223
|
+
return `bottom: max(${lift}px, ${overlay}); margin-bottom: max(0px, calc(${lift}px - ${overlay}));`;
|
|
224
|
+
}
|
|
225
|
+
return `bottom: ${overlay};`;
|
|
226
|
+
});
|
|
171
227
|
</script>
|
|
172
228
|
|
|
173
229
|
<!-- Nothing here takes focus by itself, and the bar never moves focus. -->
|
|
@@ -176,15 +232,18 @@
|
|
|
176
232
|
role={label ? "group" : undefined}
|
|
177
233
|
aria-label={label}
|
|
178
234
|
data-keyboard-open={keyboardOpen ? "true" : undefined}
|
|
235
|
+
data-bar-edge="bottom"
|
|
236
|
+
data-scrolled-under={String(scrolledUnder)}
|
|
179
237
|
class={cn(
|
|
180
238
|
// `mt-auto`: in a column taller than its fields, the bar goes to the bottom.
|
|
181
|
-
"sticky bottom-0 z-sticky mt-auto flex flex-wrap items-center justify-end gap-[8px]
|
|
239
|
+
"material-bar sticky bottom-0 z-sticky mt-auto flex flex-wrap items-center justify-end gap-[8px] pt-3",
|
|
182
240
|
"pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))]",
|
|
183
|
-
// The home indicator is under the keyboard while that is up
|
|
184
|
-
|
|
241
|
+
// The home indicator is under the keyboard while that is up, and under
|
|
242
|
+
// the footer, which already clears it, when there is one.
|
|
243
|
+
keyboardOpen || underFooter ? "pb-3" : "pb-[max(0.75rem,env(safe-area-inset-bottom))]",
|
|
185
244
|
className,
|
|
186
245
|
)}
|
|
187
|
-
style="{
|
|
246
|
+
style="{offsetStyle} {style}"
|
|
188
247
|
{...restProps}
|
|
189
248
|
>
|
|
190
249
|
{@render children?.()}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Snippet } from "svelte";
|
|
2
2
|
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import { type ScrollEdgeMode } from "../util/scroll-edge.js";
|
|
3
4
|
/**
|
|
4
5
|
* Keeps a form's main button on screen: at the bottom of whatever
|
|
5
6
|
* scrolls, and above the on-screen keyboard when that is up.
|
|
@@ -41,7 +42,12 @@ import type { HTMLAttributes } from "svelte/elements";
|
|
|
41
42
|
*
|
|
42
43
|
* In an `AppShell`, a screen with a form is a task of its own: leave the
|
|
43
44
|
* tab bar out (no `footer`) and let this bar be the bottom of the screen.
|
|
44
|
-
* With a tab bar it sits directly above it
|
|
45
|
+
* With a tab bar it sits directly above it: the shell's footer lies over
|
|
46
|
+
* the scroller, and the bar sticks to the footer's top edge, not under it.
|
|
47
|
+
*
|
|
48
|
+
* The bar is flush with the page and page-coloured while the form ends
|
|
49
|
+
* where the bar is; once content is under it, it turns to frosted glass
|
|
50
|
+
* with a hairline above it.
|
|
45
51
|
*
|
|
46
52
|
* `UnsavedChangesBar` is the other bar at the bottom of a form. That one
|
|
47
53
|
* appears only while there is something to save, announces itself, and
|
|
@@ -54,6 +60,13 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "style"> & {
|
|
|
54
60
|
* says what the buttons belong to; without it, it is a plain box.
|
|
55
61
|
*/
|
|
56
62
|
label?: string;
|
|
63
|
+
/**
|
|
64
|
+
* When the bar shows the glass and the hairline. `auto` follows the
|
|
65
|
+
* scroll position: flush at the end of the content, glass while
|
|
66
|
+
* content is under it. `always` is glass all the time, `never` flush
|
|
67
|
+
* all the time.
|
|
68
|
+
*/
|
|
69
|
+
scrollEdge?: ScrollEdgeMode;
|
|
57
70
|
class?: string;
|
|
58
71
|
/** Added after the offset and margin the bar sets while the keyboard is up. */
|
|
59
72
|
style?: string;
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import { generateId } from "../util/ssr-safe.js";
|
|
4
4
|
import { cn } from "../util/cn.js";
|
|
5
|
+
import {
|
|
6
|
+
afterFrame,
|
|
7
|
+
measureIndicator,
|
|
8
|
+
observeSizes,
|
|
9
|
+
type IndicatorBox,
|
|
10
|
+
} from "../util/sliding-indicator.js";
|
|
5
11
|
|
|
6
12
|
interface Props {
|
|
7
13
|
/** Extra classes for the host element. */
|
|
@@ -140,6 +146,50 @@
|
|
|
140
146
|
return () => observer.disconnect();
|
|
141
147
|
});
|
|
142
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The sliding indicator: the underline, or the pill. Until a box has been
|
|
151
|
+
* measured (on the server, before hydration, with no layout) it is null and
|
|
152
|
+
* the selected tab draws its own mark; with a box, that mark moves to the
|
|
153
|
+
* indicator, which slides between tabs. It lives in the scrolling list and
|
|
154
|
+
* is placed against the list's content, so it stays on its tab as the list
|
|
155
|
+
* scrolls.
|
|
156
|
+
*/
|
|
157
|
+
let indicator = $state<IndicatorBox | null>(null);
|
|
158
|
+
/** Off for the first placement, so the indicator appears in place and does not fly in. */
|
|
159
|
+
let indicatorMotion = $state(false);
|
|
160
|
+
|
|
161
|
+
function measure() {
|
|
162
|
+
const tab = tabElements[activeTab];
|
|
163
|
+
indicator = listElement && tab ? measureIndicator(listElement, tab) : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
$effect(() => {
|
|
167
|
+
void [tabs, activeTab, variant, fullWidth, listElement];
|
|
168
|
+
measure();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The tabs resize with a loaded font, zoomed text or a wider row.
|
|
172
|
+
$effect(() => {
|
|
173
|
+
const list = listElement;
|
|
174
|
+
void tabs.length;
|
|
175
|
+
if (!list) return;
|
|
176
|
+
return observeSizes([list, ...Object.values(tabElements)], measure);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
$effect(() => {
|
|
180
|
+
if (!indicator || indicatorMotion) return;
|
|
181
|
+
return afterFrame(() => (indicatorMotion = true));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/** The underline is the bottom 2px of the tab's box; the pill is all of it. */
|
|
185
|
+
const indicatorStyle = $derived.by(() => {
|
|
186
|
+
if (!indicator) return "";
|
|
187
|
+
const { x, y, width, height } = indicator;
|
|
188
|
+
const line = variant === "pills" ? height : 2;
|
|
189
|
+
const top = variant === "pills" ? y : y + height - 2;
|
|
190
|
+
return `transform: translate(${x}px, ${top}px); width: ${width}px; height: ${line}px;`;
|
|
191
|
+
});
|
|
192
|
+
|
|
143
193
|
/**
|
|
144
194
|
* Arrow keys and Tab move focus to a tab that may be out of view. Keyboard
|
|
145
195
|
* focus only: a press focuses the tab on the way down, and moving the list
|
|
@@ -211,7 +261,7 @@
|
|
|
211
261
|
}
|
|
212
262
|
|
|
213
263
|
const TAB_BASE =
|
|
214
|
-
"focus-ring cursor-pointer border-b-2 px-4 py-2 pointer-coarse:min-h-11 text-sm font-medium transition-colors focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed";
|
|
264
|
+
"focus-ring relative cursor-pointer border-b-2 px-4 py-2 pointer-coarse:min-h-11 text-sm font-medium transition-colors focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed";
|
|
215
265
|
// A tab keeps its width and the list scrolls; sharing the row, it may shrink and its label wrap.
|
|
216
266
|
const TAB_NATURAL = "shrink-0 whitespace-nowrap";
|
|
217
267
|
// `min-w-min` is the tab's longest word: a label wraps between words, never
|
|
@@ -231,9 +281,20 @@
|
|
|
231
281
|
const TAB_SELECTED_PILL =
|
|
232
282
|
"border-brand-500 bg-action-primary-subtle text-link active:bg-action-primary-subtle-active";
|
|
233
283
|
|
|
284
|
+
// With the indicator the tab keeps its label colour and its pressed fill
|
|
285
|
+
// only. Forced colours drop backgrounds, so the underline of the tab itself
|
|
286
|
+
// stays there and the indicator is hidden.
|
|
287
|
+
const TAB_SELECTED_MOVED =
|
|
288
|
+
"border-brand-500 not-forced-colors:border-transparent text-body active:bg-surface-active";
|
|
289
|
+
const TAB_SELECTED_PILL_MOVED =
|
|
290
|
+
"border-brand-500 not-forced-colors:border-transparent text-link active:bg-action-primary-subtle-active";
|
|
291
|
+
|
|
234
292
|
function tabClasses(tabId: string): string {
|
|
235
293
|
const base = `${TAB_BASE} ${fullWidth ? TAB_SHARED : TAB_NATURAL}`;
|
|
236
294
|
if (activeTab !== tabId) return `${base} ${TAB_IDLE}`;
|
|
295
|
+
if (indicator) {
|
|
296
|
+
return `${base} ${variant === "pills" ? TAB_SELECTED_PILL_MOVED : TAB_SELECTED_MOVED}`;
|
|
297
|
+
}
|
|
237
298
|
return `${base} ${variant === "pills" ? TAB_SELECTED_PILL : TAB_SELECTED}`;
|
|
238
299
|
}
|
|
239
300
|
</script>
|
|
@@ -253,7 +314,7 @@
|
|
|
253
314
|
<!-- `tabindex="-1"`: a scrolling box is otherwise a Tab stop of its own. -->
|
|
254
315
|
<div
|
|
255
316
|
bind:this={listElement}
|
|
256
|
-
class="tabs-list flex"
|
|
317
|
+
class="tabs-list relative flex"
|
|
257
318
|
role="presentation"
|
|
258
319
|
tabindex="-1"
|
|
259
320
|
data-fade-left={fadeLeft ? "" : undefined}
|
|
@@ -261,6 +322,22 @@
|
|
|
261
322
|
onfocusin={handleFocusIn}
|
|
262
323
|
onscroll={updateFades}
|
|
263
324
|
>
|
|
325
|
+
{#if indicator}
|
|
326
|
+
<!-- Under the tabs (they are positioned and come later), and never in their way. -->
|
|
327
|
+
<span
|
|
328
|
+
class={cn(
|
|
329
|
+
"pointer-events-none absolute top-0 left-0 forced-colors:hidden",
|
|
330
|
+
variant === "pills" ? "bg-action-primary-subtle border-b-2 border-brand-500" : "bg-brand-500",
|
|
331
|
+
// The underline must not overshoot; the pill may spring.
|
|
332
|
+
indicatorMotion &&
|
|
333
|
+
"transition-[transform,width,height] duration-(--duration-moderate) motion-reduce:transition-none",
|
|
334
|
+
indicatorMotion && (variant === "pills" ? "ease-spring" : "ease-standard"),
|
|
335
|
+
)}
|
|
336
|
+
aria-hidden="true"
|
|
337
|
+
data-indicator={variant}
|
|
338
|
+
style={indicatorStyle}
|
|
339
|
+
></span>
|
|
340
|
+
{/if}
|
|
264
341
|
{#each tabs as tab (tab.id)}
|
|
265
342
|
<button
|
|
266
343
|
bind:this={tabElements[tab.id]}
|