srcdev-nuxt-components 9.6.14 → 9.6.15
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/.claude/skills/components/sliding-toggle.md +108 -0
- package/.claude/skills/components/tabbed-content.md +8 -3
- package/.claude/skills/composable-sliding-indicator.md +60 -0
- package/.claude/skills/index.md +2 -0
- package/.claude/skills/storybook-add-story.md +17 -0
- package/.claude/skills/testing-add-unit-test.md +25 -0
- package/.claude/skills/theming-component-token-pattern.md +18 -0
- package/.vscode/srcdev-component-sliding-toggle.code-snippets +37 -0
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -1
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +15 -2
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +4 -4
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +4 -4
- package/app/components/02.molecules/sliding-toggle/CONSUMER-STYLING.md +109 -0
- package/app/components/02.molecules/sliding-toggle/SlidingToggle.vue +307 -0
- package/app/components/02.molecules/sliding-toggle/stories/SlidingToggle.stories.ts +357 -0
- package/app/components/02.molecules/sliding-toggle/tests/SlidingToggle.spec.ts +335 -0
- package/app/components/02.molecules/sliding-toggle/tests/__snapshots__/SlidingToggle.spec.ts.snap +3 -0
- package/app/composables/tests/useSlidingIndicator.spec.ts +143 -0
- package/app/composables/useSlidingIndicator.ts +56 -0
- package/app/composables/useTabs.ts +4 -31
- package/app/types/components/index.ts +1 -0
- package/app/types/components/sliding-toggle.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: SlidingToggle
|
|
3
|
+
description: SlidingToggle single-choice segmented radiogroup with text labels of any width, measured sliding indicator (stretch-then-catch-up) plus hover indicator, corner-shape token, CSS token API
|
|
4
|
+
type: reference
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# SlidingToggle
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
A single-choice segmented control for short text choices such as a billing period or a site tier.
|
|
12
|
+
The selected option's background slides between options of different widths, with the same
|
|
13
|
+
stretch-then-catch-up motion as `TabbedContent`'s indicator (both use `useSlidingIndicator`), and a
|
|
14
|
+
second indicator follows the pointer. It's a WAI-ARIA radio group of `<button role="radio">`s, not
|
|
15
|
+
a form control: there are no native inputs and nothing is submitted.
|
|
16
|
+
|
|
17
|
+
Lives in `app/components/02.molecules/sliding-toggle/`.
|
|
18
|
+
|
|
19
|
+
**Which one to use:**
|
|
20
|
+
|
|
21
|
+
- `SlidingToggle` — text labels of any width, page UI state (pricing toggles, view switches).
|
|
22
|
+
- `SegmentedControl` (`05.forms`) — icon-only, equal-width options, native radio inputs that submit
|
|
23
|
+
with a form.
|
|
24
|
+
- `TabbedContent` — the choice switches between panels of content.
|
|
25
|
+
|
|
26
|
+
## Props
|
|
27
|
+
|
|
28
|
+
| Prop (template form) | Type | Default | Notes |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| `v-model` | `T` (`string \| number`) | (required) | The selected option's `value`. A value matching no option leaves nothing selected. |
|
|
31
|
+
| `options` | `SlidingToggleOption<T>[]` | (required) | `{ value, label, disabled? }`. Labels are plain text. |
|
|
32
|
+
| `aria-label` | `string` | `""` | Names the radiogroup. Set this or `aria-labelledby`. |
|
|
33
|
+
| `aria-labelledby` | `string` | `""` | Id of a visible heading naming the group. |
|
|
34
|
+
| `disabled` | `boolean` | `false` | Disables every option. |
|
|
35
|
+
| `track-hover` | `boolean` | `true` | Show the hover indicator. |
|
|
36
|
+
| `transition-duration` | `number` (ms) | `200` | Length of each edge's slide. |
|
|
37
|
+
| `style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root. |
|
|
38
|
+
|
|
39
|
+
`SlidingToggleOption` is exported from `~/types/components`.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```vue
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
const billing = ref<"monthly" | "yearly">("monthly");
|
|
46
|
+
const billingOptions = [
|
|
47
|
+
{ value: "monthly", label: "Monthly" },
|
|
48
|
+
{ value: "yearly", label: "Yearly (save 20%)" },
|
|
49
|
+
] as const;
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<SlidingToggle v-model="billing" :options="[...billingOptions]" aria-label="Billing period" />
|
|
54
|
+
</template>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Equal widths across a full-width switch:
|
|
58
|
+
|
|
59
|
+
```css
|
|
60
|
+
.billing-switch {
|
|
61
|
+
--sliding-toggle-option-flex: 1 1 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.billing-switch .sliding-toggle {
|
|
65
|
+
inline-size: 100%;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Behaviour
|
|
70
|
+
|
|
71
|
+
- The root is `role="radiogroup"`, named by `aria-label` or `aria-labelledby`. Each option is a
|
|
72
|
+
`<button type="button" role="radio">` with `aria-checked`.
|
|
73
|
+
- Clicking an option sets `v-model`; changing `v-model` from outside moves the selection.
|
|
74
|
+
- Only the selected option is a tab stop. With no match, the first enabled option is.
|
|
75
|
+
- Arrow keys move focus and select, wrapping round and skipping disabled options. Right and Down go
|
|
76
|
+
to the next option, Left and Up to the previous; inside `dir="rtl"` Left and Right swap. Home and
|
|
77
|
+
End go to the first and last enabled option.
|
|
78
|
+
- `disabled` on the group or on one option blocks clicks and keys; disabled options are skipped and
|
|
79
|
+
never get the hover indicator.
|
|
80
|
+
- The selected indicator slides to the new option, its leading edge first and the trailing edge one
|
|
81
|
+
`transition-duration` later. The hover indicator follows the pointer and returns to the selected
|
|
82
|
+
option when it leaves; `track-hover="false"` turns it off. `--sliding-toggle-indicator-stretch: 0`
|
|
83
|
+
makes both indicators slide their edges together instead of stretching, mirroring TabbedContent's
|
|
84
|
+
`--tabs-indicator-stretch`.
|
|
85
|
+
- Under `prefers-reduced-motion` both indicators snap.
|
|
86
|
+
- Before it's mounted and measured (SSR, first paint) the indicators are hidden and the selected
|
|
87
|
+
option paints its own background and marker, so there's no flash of an unselected group.
|
|
88
|
+
- It re-measures when the track or any single option resizes (a late web font changes label widths
|
|
89
|
+
without resizing the track) and when options are added, removed or re-classed.
|
|
90
|
+
- Long, unbroken and many labels wrap and shrink inside the track instead of overflowing.
|
|
91
|
+
|
|
92
|
+
## CSS custom properties
|
|
93
|
+
|
|
94
|
+
See `CONSUMER-STYLING.md` for the full `--sliding-toggle-*` token API: track surface, padding, gap,
|
|
95
|
+
radii, `--sliding-toggle-corner-shape`, indicator and hover-indicator surfaces, marker, option
|
|
96
|
+
padding/flex/typography/colours and the focus ring. Colours default from `--theme-*` tokens.
|
|
97
|
+
|
|
98
|
+
`--sliding-toggle-corner-shape` takes any `corner-shape` value (`squircle`, `superellipse(n)`,
|
|
99
|
+
`bevel`, `scoop`, `notch`). It's a progressive enhancement: Chromium 139+ only, and other browsers
|
|
100
|
+
keep the `border-radius` corners.
|
|
101
|
+
|
|
102
|
+
## Notes
|
|
103
|
+
|
|
104
|
+
- Auto-imported in Nuxt.
|
|
105
|
+
- The motion comes from the `useSlidingIndicator` composable, shared with `TabbedContent` (via
|
|
106
|
+
`useTabs`). It writes `--_sliding-toggle-active-*` / `--_sliding-toggle-hover-*` on the root.
|
|
107
|
+
- Added 2026-10-19, generalised from a consumer app's pricing-page toggle (which used `aria-pressed`
|
|
108
|
+
buttons; this version uses radio semantics).
|
|
@@ -137,6 +137,10 @@ source order when the list changes.
|
|
|
137
137
|
|
|
138
138
|
## Indicator motion: edge insets (2026-10-08)
|
|
139
139
|
|
|
140
|
+
> **Changed 2026-10-19**: the edge maths moved into `useSlidingIndicator` (`composable-sliding-indicator.md`),
|
|
141
|
+
> shared with `SlidingToggle`. `useTabs.placeIndicator` now calls it and keeps the label-coverage
|
|
142
|
+
> tracking. Under `prefers-reduced-motion` the indicators now snap instead of sliding.
|
|
143
|
+
|
|
140
144
|
`useTabs.placeIndicator(kind, animate)` writes each indicator's two edges as insets from the
|
|
141
145
|
bar's start/end along the axis (`--_{kind}-start` / `--_{kind}-end`), and the CSS transitions
|
|
142
146
|
`left`/`right` (or `top`/`bottom`) separately. Moving forward, the start edge gets a delay of one
|
|
@@ -145,9 +149,10 @@ and the trailing edge catches up: the same stretch effect as before, symmetric b
|
|
|
145
149
|
settle timer. CSS retargets each edge from its current position, so a quick second move can't
|
|
146
150
|
pull an edge backwards. Hover and active have separate duration/delay variables.
|
|
147
151
|
|
|
148
|
-
`--tabs-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
`--tabs-indicator-stretch` (default `1`) multiplies every indicator's two edge delays in CSS, so `0`
|
|
153
|
+
turns the stretch into a plain slide (both edges move together over the same duration) for the
|
|
154
|
+
hover, active and underline indicators alike. Renamed 2026-10-19 from `--tabs-hover-indicator-stretch`,
|
|
155
|
+
which covered the hover pair only.
|
|
151
156
|
|
|
152
157
|
This replaced a `translate` + `scale` model (a bar-wide box translated to the tab's left and
|
|
153
158
|
scaled to its width, plus a timeout to settle after a stretch, and one shared
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# useSlidingIndicator
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Positions one or more sliding indicators (a highlight or underline that moves between items) inside
|
|
6
|
+
a container, with a stretch-then-catch-up motion: the edge in the direction of travel moves first
|
|
7
|
+
and the trailing edge follows one duration later. Used by `useTabs` (TabbedContent) and
|
|
8
|
+
`SlidingToggle`. Lives in `app/composables/useSlidingIndicator.ts`, auto-imported.
|
|
9
|
+
|
|
10
|
+
## API
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const { place, reset } = useSlidingIndicator(containerRef, { axis?: "x" | "y", duration });
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Param | Type | Notes |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `containerRef` | `Ref<HTMLElement \| null>` | The positioned element the indicators sit in. The CSS variables are written on it. |
|
|
19
|
+
| `axis` | `MaybeRefOrGetter<"x" \| "y">` | Default `"x"`. |
|
|
20
|
+
| `duration` | `MaybeRefOrGetter<number>` | Slide length per edge, in ms. Invalid or negative values count as `0`. |
|
|
21
|
+
|
|
22
|
+
`place(name, target, animate)` measures `target` (its `offsetLeft`/`offsetTop` and size against the
|
|
23
|
+
container's client size) and writes, on the container:
|
|
24
|
+
|
|
25
|
+
- `--_{name}-start` / `--_{name}-end` — insets from the container's start and end edges, in px;
|
|
26
|
+
- `--_{name}-duration`, `--_{name}-start-delay`, `--_{name}-end-delay`.
|
|
27
|
+
|
|
28
|
+
It returns the transition length in ms, or `null` when the container or target is missing (nothing
|
|
29
|
+
written). The first placement of a name, `animate: false`, and `prefers-reduced-motion` all snap
|
|
30
|
+
(duration `0ms`).
|
|
31
|
+
|
|
32
|
+
`reset(name)` forgets the last position, so the next placement snaps instead of sliding in from
|
|
33
|
+
wherever the indicator last was (use it when an indicator is hidden).
|
|
34
|
+
|
|
35
|
+
## CSS side
|
|
36
|
+
|
|
37
|
+
```css
|
|
38
|
+
.my-indicator {
|
|
39
|
+
position: absolute;
|
|
40
|
+
left: var(--_my-thing-active-start, 0px);
|
|
41
|
+
right: var(--_my-thing-active-end, 100%);
|
|
42
|
+
transition:
|
|
43
|
+
left var(--_my-thing-active-duration, 0ms) ease var(--_my-thing-active-start-delay, 0ms),
|
|
44
|
+
right var(--_my-thing-active-duration, 0ms) ease var(--_my-thing-active-end-delay, 0ms);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use physical `left`/`right` (and `top`/`bottom` for `"y"`): the measurements are physical, so this
|
|
49
|
+
also works in right-to-left layouts.
|
|
50
|
+
|
|
51
|
+
## Rules
|
|
52
|
+
|
|
53
|
+
- Prefix the `name` with the component (`"sliding-toggle-active"`), so a component nested inside
|
|
54
|
+
another that uses the composable can't inherit its values (pitfall #26). `useTabs` keeps the
|
|
55
|
+
historic `"active"`/`"hovered"` names that TabbedContent's CSS reads.
|
|
56
|
+
- Re-place with `animate: false` when the container or any measured item resizes, and when items
|
|
57
|
+
are added or removed (`measured-layout-observers.md`). Don't observe `style` changes: the
|
|
58
|
+
composable writes inline styles on the container.
|
|
59
|
+
- Keep the indicators hidden until the first placement (SlidingToggle's `data-ready`), and give the
|
|
60
|
+
selected item its own fallback background for SSR and first paint.
|
package/.claude/skills/index.md
CHANGED
|
@@ -75,6 +75,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
75
75
|
├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
|
|
76
76
|
├── composable-analytics.md — useAnalytics: provider-agnostic trackEvent/page-view tracking (google-analytics only implemented), consent-gated, single call site for setup + firing events
|
|
77
77
|
├── composable-anchored-popover.md — useAnchoredPopover: popover + anchor positioning with JS fallbacks for Safari 16 (no Popover API) and 17–18 (no anchor positioning); used by ActionMenu/SelectMenu/DeepExpandingMenu/DisplayTooltip/PopOver
|
|
78
|
+
├── composable-sliding-indicator.md — useSlidingIndicator: stretch-then-catch-up sliding indicator edges written as --_{name}-* vars, reduced-motion snap; used by useTabs/TabbedContent and SlidingToggle
|
|
78
79
|
└── components/
|
|
79
80
|
├── alert-content.md — AlertContent + AlertContentInner: themed alert panel (icon/title/content/actions/dismiss) under DisplayToast, DisplayPrompt and AlertMaskedContent; showIcon, #actions row, --alert-content-* tokens, app.config icon map
|
|
80
81
|
├── alert-mask.md — AlertMask (renamed from AlertMaskCore): SVG border/fill sized to slotted content via ResizeObserver; config-prop geometry, --alert-mask-* colour tokens
|
|
@@ -164,6 +165,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
164
165
|
├── input-radio.md — MultipleRadiobuttons: fieldset radiogroup (data-driven, button or labelled), shared-name grouping bug fixed, dead label/placeholder/equalCols props removed, data-options-layout hook, spacing token API, useId button ids, wraps hostile content
|
|
165
166
|
├── toggle-switch.md — ToggleSwitch (bare control), ToggleSwitchField (labelled, layout="stacked"|"inline", replaces ToggleSwitchWithLabelInline): renamed from Core/WithLabel, role="switch", prefixed inner classes + data-checked, single-toggle click handling, reduced motion, round prop + border-radius token, track width from geometry, CSS token API, stress stories
|
|
166
167
|
├── segmented-control.md — SegmentedControl (renamed from TripleToggleSwitch): icon radio pill for any number of options with sliding marker, radiogroup + ariaLabel, system/light/dark marker-gradient tokens, reduced-motion, CSS token API
|
|
168
|
+
├── sliding-toggle.md — SlidingToggle: single-choice segmented radiogroup with text labels of any width, measured sliding + hover indicators (useSlidingIndicator), roving tabindex/arrow keys, corner-shape token, CSS token API
|
|
167
169
|
├── entry-animation.md — EntryAnimation: scroll-driven entry animation wrapper (slide-in/zoom-reveal/exit-blur utility classes), skipAnimation for above-the-fold loop items, reduced-motion handled at the CSS layer
|
|
168
170
|
├── column-flow-grid.md — ColumnFlowGrid (renamed from MasonryGrid): CSS multi-column text-flow layout, named dynamic slots (no count/data prop), itemMinWidth/gap/unit sizing, CSS token API; not a true masonry, see "which one do I want?"
|
|
169
171
|
├── masonry-grid.md — MasonryGrid: real measured-height masonry (greedy shortest-column packing, animated resize) absorbed from the now-retired MasonryGridOrdered; named dynamic slots, fixedWidth/justify, CSS token API
|
|
@@ -172,6 +172,23 @@ const Template: StoryFn<StoryArgs> = (args) => ({
|
|
|
172
172
|
|
|
173
173
|
Don't name the inline component the same as an exported story (`TS2451: Cannot redeclare`).
|
|
174
174
|
|
|
175
|
+
### Generic components (`<script setup generic="T">`)
|
|
176
|
+
|
|
177
|
+
A generic component's type doesn't fit `Meta`'s `component` field (`ConcreteComponent<Args>`), so
|
|
178
|
+
`npx nuxi typecheck` fails on `component: MyGeneric`. Cast it, with a one-line comment; the
|
|
179
|
+
autodocs props table still generates:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import type { ConcreteComponent } from "vue";
|
|
183
|
+
|
|
184
|
+
const meta: Meta<StoryArgs> = {
|
|
185
|
+
// Generic components don't fit Meta's ConcreteComponent<Args>; the props table still generates.
|
|
186
|
+
component: SlidingToggle as unknown as ConcreteComponent<StoryArgs>,
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`SlidingToggle.stories.ts` is the reference.
|
|
191
|
+
|
|
175
192
|
## Title format
|
|
176
193
|
|
|
177
194
|
`"Category/Subcategory/ComponentName"` — this becomes the Storybook sidebar path and the
|
|
@@ -396,6 +396,31 @@ afterEach(() => {
|
|
|
396
396
|
The `as unknown as Record<string, unknown>` double-cast is required because TypeScript's
|
|
397
397
|
`HTMLElement` type has no index signature — cast through `unknown` first.
|
|
398
398
|
|
|
399
|
+
## A mount helper that keeps `v-model` in sync
|
|
400
|
+
|
|
401
|
+
To test a `v-model` component through clicks and keys, feed `update:modelValue` back into the
|
|
402
|
+
props. The handler needs the wrapper it's creating, so hold it in a `const` object: a self-referencing
|
|
403
|
+
`const wrapper` is an implicit `any` under strict mode, and `let wrapper` assigned once fails
|
|
404
|
+
ESLint's `prefer-const`.
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const mountToggle = async (props: Record<string, unknown> = {}) => {
|
|
408
|
+
const holder: { current?: Awaited<ReturnType<typeof mountSuspended>> } = {};
|
|
409
|
+
const onUpdate = (value: string | number) => {
|
|
410
|
+
void holder.current?.setProps({ modelValue: value });
|
|
411
|
+
};
|
|
412
|
+
const wrapper = await mountSuspended(SlidingToggle, {
|
|
413
|
+
props: { modelValue: "monthly", "onUpdate:modelValue": onUpdate, ...props },
|
|
414
|
+
});
|
|
415
|
+
holder.current = wrapper;
|
|
416
|
+
mounted.push(wrapper);
|
|
417
|
+
return wrapper;
|
|
418
|
+
};
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Type the handler's `value` as the model's full type (`string | number` for a generic model), not
|
|
422
|
+
the narrower one a test happens to use.
|
|
423
|
+
|
|
399
424
|
## Describe section conventions
|
|
400
425
|
|
|
401
426
|
Use these section names consistently so tests are easy to scan:
|
|
@@ -129,6 +129,24 @@ Prefer this over a prop: line count is presentation, and CSS handles it without
|
|
|
129
129
|
layout around it.
|
|
130
130
|
- Document it in `CONSUMER-STYLING.md` with its default and the `1`/`none` options.
|
|
131
131
|
|
|
132
|
+
## Corner shape tokens
|
|
133
|
+
|
|
134
|
+
`corner-shape` (CSS Borders 4: `squircle`, `superellipse(n)`, `bevel`, `scoop`, `notch`, default
|
|
135
|
+
`round`) reshapes the curve that `border-radius` draws. Give a component that wants it one public
|
|
136
|
+
token, `--<component>-corner-shape`, defaulting to `round`, and read it on every element that has a
|
|
137
|
+
radius so they stay in step:
|
|
138
|
+
|
|
139
|
+
```css
|
|
140
|
+
.sliding-toggle,
|
|
141
|
+
.sliding-toggle-option {
|
|
142
|
+
corner-shape: var(--sliding-toggle-corner-shape, round);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It's a progressive enhancement (Chromium 139+ when added on 2026-10-19): other browsers drop the
|
|
147
|
+
declaration and keep the `border-radius` corners, so never rely on it for layout or meaning. Label
|
|
148
|
+
it as Chromium-only in `CONSUMER-STYLING.md`. `SlidingToggle` is the first user.
|
|
149
|
+
|
|
132
150
|
## Text over an image: scrim tokens
|
|
133
151
|
|
|
134
152
|
Any component that puts text over a photo (a header in a picture, a card title on its image) gets
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"SRCDEV SlidingToggle": {
|
|
3
|
+
"description": "SlidingToggle — single-choice segmented radiogroup with a sliding indicator",
|
|
4
|
+
"scope": "vue,html",
|
|
5
|
+
"body": [
|
|
6
|
+
"<SlidingToggle",
|
|
7
|
+
" v-model=\"${1:selected}\"",
|
|
8
|
+
" :options=\"[",
|
|
9
|
+
" { value: '${2:monthly}', label: '${3:Monthly}' },",
|
|
10
|
+
" { value: '${4:yearly}', label: '${5:Yearly}' },",
|
|
11
|
+
" ]\"",
|
|
12
|
+
" aria-label=\"${6:Billing period}\"",
|
|
13
|
+
"/>"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"SRCDEV SlidingToggle Options Variable": {
|
|
17
|
+
"description": "SlidingToggle bound to an options array, without hover tracking",
|
|
18
|
+
"scope": "vue,html",
|
|
19
|
+
"body": [
|
|
20
|
+
"<SlidingToggle v-model=\"${1:selected}\" :options=\"${2:options}\" aria-label=\"${3:Label}\"${4: :track-hover=\"false\"} />"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"SRCDEV SlidingToggle CSS Override": {
|
|
24
|
+
"description": "Unlayered CSS override scaffold for SlidingToggle tokens",
|
|
25
|
+
"scope": "css,vue",
|
|
26
|
+
"body": [
|
|
27
|
+
".${1:my-section} {",
|
|
28
|
+
" --sliding-toggle-surface: ${2:var(--theme-surface-subtle)};",
|
|
29
|
+
" --sliding-toggle-indicator-surface: ${3:var(--theme-on-surface)};",
|
|
30
|
+
" --sliding-toggle-marker-colour: ${4:var(--theme-accent)};",
|
|
31
|
+
" --sliding-toggle-option-flex: ${5|0 1 auto,1 1 0|};",
|
|
32
|
+
" --sliding-toggle-corner-shape: ${6|round,squircle,superellipse(3),bevel,scoop,notch|};",
|
|
33
|
+
" --sliding-toggle-indicator-stretch: ${7|1,0|};$0",
|
|
34
|
+
"}"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
" --tabs-underline-indicator-height: ;",
|
|
59
59
|
" --tabs-hover-underline-indicator-colour: ;",
|
|
60
60
|
" --tabs-hover-underline-indicator-height: ;",
|
|
61
|
-
" --tabs-
|
|
61
|
+
" --tabs-indicator-stretch: ;",
|
|
62
62
|
" --tabs-list-item-opacity: ;",
|
|
63
63
|
" --tabs-list-item-colour: ;",
|
|
64
64
|
" --tabs-list-item-colour-selected: ;",
|
|
@@ -58,7 +58,7 @@ box.
|
|
|
58
58
|
| `--tabs-underline-indicator-height` | `0.4rem` | Thickness of the underline (`axis="x"`), also its width on `axis="y"` |
|
|
59
59
|
| `--tabs-hover-underline-indicator-colour` | `--tabs-underline-indicator-colour` | Colour of the thin underline/sideline that follows the hovered tab (`trackHover` and `trackIndicator`) |
|
|
60
60
|
| `--tabs-hover-underline-indicator-height` | `0.1rem` | Its thickness; `0` hides it. Sits under the active underline where they overlap |
|
|
61
|
-
| `--tabs-
|
|
61
|
+
| `--tabs-indicator-stretch` | `1` | Stretch of every indicator (hover, active and both underlines) as it moves: `1` stretches (leading edge first, trailing edge catches up), `0` slides both edges together. Values in between shorten the stretch |
|
|
62
62
|
|
|
63
63
|
### More menu (`overflowMode="menu"`, `axis="x"`)
|
|
64
64
|
|
|
@@ -100,7 +100,7 @@ insets from the bar's start and end along the axis), `--_{kind}-duration`,
|
|
|
100
100
|
`--_{kind}-start-delay` / `--_{kind}-end-delay`; each indicator reads them through its own
|
|
101
101
|
`--_start` / `--_end` / `--_duration` / `--_start-delay` / `--_end-delay`. Not public API; use the
|
|
102
102
|
`transitionDuration` prop for indicator speed (one edge moves for that long, then the other), and
|
|
103
|
-
`--tabs-
|
|
103
|
+
`--tabs-indicator-stretch` to turn the stretch off. The More menu also uses
|
|
104
104
|
`--_anchor-name` (a per-instance anchor name) and the `--_anchor-*` position values
|
|
105
105
|
`useAnchoredPopover` writes for browsers without CSS anchor positioning.
|
|
106
106
|
|
|
@@ -169,6 +169,19 @@ before.
|
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
|
172
|
+
## Indicator motion and reduced motion
|
|
173
|
+
|
|
174
|
+
The indicators slide with `useSlidingIndicator` (shared with `SlidingToggle`): the leading edge
|
|
175
|
+
moves first and the trailing edge catches up. Under `prefers-reduced-motion` they snap.
|
|
176
|
+
|
|
177
|
+
> **Changed 2026-10-19**: the indicators used to slide even under `prefers-reduced-motion`.
|
|
178
|
+
>
|
|
179
|
+
> **Changed 2026-10-19**: `--tabs-hover-indicator-stretch` (hover only) is now
|
|
180
|
+
> `--tabs-indicator-stretch` and covers the active indicator and underline too. Rename it in your
|
|
181
|
+
> CSS; `0` now turns the stretch off for every indicator.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
172
185
|
## Indicator visibility is prop-driven
|
|
173
186
|
|
|
174
187
|
Whether each indicator renders is set by `trackHover` / `trackActive` / `trackIndicator`, not
|
|
@@ -354,8 +354,8 @@ watch(
|
|
|
354
354
|
--_start: var(--_hovered-start, 0px);
|
|
355
355
|
--_end: var(--_hovered-end, 100%);
|
|
356
356
|
--_duration: var(--_hovered-duration, 0ms);
|
|
357
|
-
--_start-delay: calc(var(--_hovered-start-delay, 0ms) * var(--tabs-
|
|
358
|
-
--_end-delay: calc(var(--_hovered-end-delay, 0ms) * var(--tabs-
|
|
357
|
+
--_start-delay: calc(var(--_hovered-start-delay, 0ms) * var(--tabs-indicator-stretch, 1));
|
|
358
|
+
--_end-delay: calc(var(--_hovered-end-delay, 0ms) * var(--tabs-indicator-stretch, 1));
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
.tabbed-content-indicator-active,
|
|
@@ -363,8 +363,8 @@ watch(
|
|
|
363
363
|
--_start: var(--_active-start, 0px);
|
|
364
364
|
--_end: var(--_active-end, 100%);
|
|
365
365
|
--_duration: var(--_active-duration, 0ms);
|
|
366
|
-
--_start-delay: var(--_active-start-delay, 0ms);
|
|
367
|
-
--_end-delay: var(--_active-end-delay, 0ms);
|
|
366
|
+
--_start-delay: calc(var(--_active-start-delay, 0ms) * var(--tabs-indicator-stretch, 1));
|
|
367
|
+
--_end-delay: calc(var(--_active-end-delay, 0ms) * var(--tabs-indicator-stretch, 1));
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
.tabbed-content-indicator-hover,
|
|
@@ -219,14 +219,14 @@ export const IndicatorColours: Story = {
|
|
|
219
219
|
}),
|
|
220
220
|
};
|
|
221
221
|
|
|
222
|
-
export const
|
|
223
|
-
name: "
|
|
222
|
+
export const WithoutStretch: Story = {
|
|
223
|
+
name: "Without Stretch",
|
|
224
224
|
args: { itemCount: 5 },
|
|
225
225
|
parameters: {
|
|
226
226
|
docs: {
|
|
227
227
|
description: {
|
|
228
228
|
story:
|
|
229
|
-
"--tabs-
|
|
229
|
+
"--tabs-indicator-stretch: 0 makes every indicator (hover, active and the underlines) slide between tabs instead of stretching across and catching up.",
|
|
230
230
|
},
|
|
231
231
|
},
|
|
232
232
|
},
|
|
@@ -236,7 +236,7 @@ export const HoverWithoutStretch: Story = {
|
|
|
236
236
|
return { args };
|
|
237
237
|
},
|
|
238
238
|
template: `
|
|
239
|
-
<div style="--tabs-
|
|
239
|
+
<div style="--tabs-indicator-stretch: 0">
|
|
240
240
|
<TabbedContent v-bind="args">
|
|
241
241
|
${tabSlots}
|
|
242
242
|
</TabbedContent>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# SlidingToggle — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
### Track
|
|
6
|
+
|
|
7
|
+
| Token | Default | Controls |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `--sliding-toggle-surface` | `var(--theme-surface-subtle)` | Track background |
|
|
10
|
+
| `--sliding-toggle-padding` | `0.4rem` | Space between the track edge and the options (the indicators sit inside it too) |
|
|
11
|
+
| `--sliding-toggle-gap` | `0.4rem` | Space between options |
|
|
12
|
+
| `--sliding-toggle-border-radius` | `0.8rem` | Track corner radius |
|
|
13
|
+
| `--sliding-toggle-corner-shape` | `round` | `corner-shape` of the track, both indicators and the options (`squircle`, `superellipse(n)`, `bevel`, `scoop`, `notch`). Chromium 139+ only; other browsers ignore it and keep `border-radius` |
|
|
14
|
+
|
|
15
|
+
### Indicators
|
|
16
|
+
|
|
17
|
+
| Token | Default | Controls |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `--sliding-toggle-indicator-surface` | `var(--theme-on-surface)` | Background of the selected option's sliding indicator |
|
|
20
|
+
| `--sliding-toggle-indicator-shadow` | `0 0.1rem 0.3rem rgb(0 0 0 / 0.1)` | Shadow on the selected indicator |
|
|
21
|
+
| `--sliding-toggle-hover-indicator-surface` | `color-mix(in oklch, var(--theme-surface-subtle), var(--theme-text) 6%)` | Background of the hover indicator, one step darker than the track |
|
|
22
|
+
| `--sliding-toggle-indicator-stretch` | `1` | Multiplies both indicators' edge delays: `1` stretches then catches up, `0` slides both edges together, values between shorten the stretch |
|
|
23
|
+
| `--sliding-toggle-marker-height` | `0.2rem` | Height of the accent line along the selected indicator's bottom edge (`0` hides it) |
|
|
24
|
+
| `--sliding-toggle-marker-colour` | `var(--theme-accent)` | Colour of that line |
|
|
25
|
+
|
|
26
|
+
### Options
|
|
27
|
+
|
|
28
|
+
| Token | Default | Controls |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `--sliding-toggle-option-padding` | `0.8rem 1.6rem` | Option padding |
|
|
31
|
+
| `--sliding-toggle-option-border-radius` | `0.6rem` | Corner radius of the options and both indicators |
|
|
32
|
+
| `--sliding-toggle-option-flex` | `0 1 auto` | Option `flex`. `1 1 0` gives equal widths (stretch the root too) |
|
|
33
|
+
| `--sliding-toggle-option-font-size` | `inherit` | Label font size |
|
|
34
|
+
| `--sliding-toggle-option-font-weight` | `500` | Label font weight |
|
|
35
|
+
| `--sliding-toggle-option-white-space` | `normal` | Label `white-space`; `nowrap` keeps each label on one line |
|
|
36
|
+
| `--sliding-toggle-option-colour` | `color-mix(in oklch, var(--theme-text) 70%, transparent)` | Label colour |
|
|
37
|
+
| `--sliding-toggle-option-colour-active` | `var(--theme-text)` | Selected option's label colour |
|
|
38
|
+
| `--sliding-toggle-transition-duration` | `200ms` | Label colour fade. The indicator slide is the `transitionDuration` prop |
|
|
39
|
+
| `--sliding-toggle-focus-ring-width` | `0.2rem` | Focus ring width |
|
|
40
|
+
| `--sliding-toggle-focus-ring-colour` | `var(--theme-border-focus)` | Focus ring colour |
|
|
41
|
+
|
|
42
|
+
Private (not public API): `--_sliding-toggle-active-*` and `--_sliding-toggle-hover-*` (`-start`,
|
|
43
|
+
`-end`, `-duration`, `-start-delay`, `-end-delay`), written on the root by `useSlidingIndicator`
|
|
44
|
+
from the measured option positions.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## State hooks
|
|
49
|
+
|
|
50
|
+
| Hook | Where | Meaning |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `[data-ready]` | root | Mounted and measured. Before this (SSR, first paint) the indicators are hidden and the selected option paints its own background and marker |
|
|
53
|
+
| `[data-has-selection]` | root | `v-model` matches an option |
|
|
54
|
+
| `[data-hovering]` | root | The pointer is over an enabled option (with `trackHover`) |
|
|
55
|
+
| `[aria-disabled="true"]` | root | The `disabled` prop |
|
|
56
|
+
| `[aria-checked="true"]` | `.sliding-toggle-option` | The selected option |
|
|
57
|
+
| `:disabled` | `.sliding-toggle-option` | A disabled option, or the whole group |
|
|
58
|
+
|
|
59
|
+
Inner classes: `.sliding-toggle-option` (each `<button role="radio">`), `.sliding-toggle-indicator`
|
|
60
|
+
with `.sliding-toggle-indicator-active` and `.sliding-toggle-indicator-hover`. The marker is the
|
|
61
|
+
active indicator's `::after`.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Motion
|
|
66
|
+
|
|
67
|
+
The indicator edges slide with `useSlidingIndicator`: on a move the edge in the direction of travel
|
|
68
|
+
goes first and the other follows one `transitionDuration` later. `--sliding-toggle-indicator-stretch: 0`
|
|
69
|
+
turns the stretch into a plain slide for both indicators (same as TabbedContent's
|
|
70
|
+
`--tabs-indicator-stretch`). Under `prefers-reduced-motion` both edges snap and the label colour
|
|
71
|
+
doesn't fade.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Local overrides
|
|
76
|
+
|
|
77
|
+
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
78
|
+
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
79
|
+
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
80
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
81
|
+
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
82
|
+
|
|
83
|
+
### Equal-width options
|
|
84
|
+
|
|
85
|
+
```css
|
|
86
|
+
.billing-switch {
|
|
87
|
+
--sliding-toggle-option-flex: 1 1 0;
|
|
88
|
+
--sliding-toggle-corner-shape: squircle;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.billing-switch .sliding-toggle {
|
|
92
|
+
inline-size: 100%;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Class passthrough
|
|
99
|
+
|
|
100
|
+
`style-class-passthrough` adds classes to the root `.sliding-toggle` (the radiogroup). Reactive
|
|
101
|
+
after mount.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Notes
|
|
106
|
+
|
|
107
|
+
- The root is `inline-size: fit-content` capped at `max-inline-size: 100%`; labels shrink and wrap
|
|
108
|
+
(`overflow-wrap: anywhere`) rather than overflow, and the indicators re-measure as they do.
|
|
109
|
+
- No line-clamp token: labels name the choice and must stay readable.
|