srcdev-nuxt-components 9.5.3 → 9.5.5
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/colour-scheme-disable.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +214 -0
- package/.claude/skills/component-inline-action-button.md +2 -2
- package/.claude/skills/components/carousel-flip.md +3 -3
- package/.claude/skills/components/clipped-panel.md +2 -2
- package/.claude/skills/components/dashboard-quad-grid.md +2 -2
- package/.claude/skills/components/dashboard-stats-grid.md +1 -1
- package/.claude/skills/components/deep-expanding-menu-classic.md +1 -1
- package/.claude/skills/components/deep-expanding-menu.md +1 -1
- package/.claude/skills/components/google-reviews.md +193 -0
- package/.claude/skills/components/input-number.md +4 -0
- package/.claude/skills/components/input-otp.md +120 -0
- package/.claude/skills/components/section-parallax.md +1 -1
- package/.claude/skills/components/select-menu.md +19 -5
- package/.claude/skills/components/triple-toggle-switch.md +7 -1
- package/.claude/skills/composable-colour-scheme.md +2 -0
- package/.claude/skills/index.md +5 -1
- package/.claude/skills/new-app-scaffold.md +1 -1
- package/.claude/skills/theming-colour-ramps.md +6 -33
- package/.claude/skills/theming-component-token-pattern.md +30 -0
- package/.claude/skills/theming-dark-mode.md +243 -0
- package/.claude/skills/theming-override-default.md +6 -7
- package/.claude/skills/theming-partial-override.md +11 -11
- package/.vscode/srcdev-component-google-reviews.code-snippets +78 -0
- package/.vscode/srcdev-component-input-otp.code-snippets +47 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +13 -0
- package/README.md +81 -41
- package/app/assets/styles/setup/01.config/_head.css +0 -9
- package/app/assets/styles/setup/03.theming/_default.css +29 -29
- package/app/assets/styles/setup/03.theming/_error.css +3 -3
- package/app/assets/styles/setup/03.theming/_theme-slots.css +13 -16
- package/app/assets/styles/setup/a11y/_variables.css +2 -2
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +7 -7
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +19 -1
- package/app/components/02.molecules/select-menu/SelectMenu.vue +54 -3
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +35 -0
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +9 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +12 -12
- package/app/components/03.organisms/google-reviews/CONSUMER-STYLING.md +110 -0
- package/app/components/03.organisms/google-reviews/GoogleReviewCard.vue +161 -0
- package/app/components/03.organisms/google-reviews/GoogleReviews.vue +275 -0
- package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +323 -0
- package/app/components/03.organisms/google-reviews/tests/GoogleReviewCard.spec.ts +104 -0
- package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +232 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +9 -0
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +43 -0
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/input-number/InputNumber.vue +2 -2
- package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +59 -0
- package/app/components/05.forms/input-otp/InputOtp.vue +254 -0
- package/app/components/05.forms/input-otp/InputOtpField.vue +106 -0
- package/app/components/05.forms/input-otp/stories/InputOtp.stories.ts +100 -0
- package/app/components/05.forms/input-otp/stories/InputOtpField.stories.ts +163 -0
- package/app/components/05.forms/input-otp/tests/InputOtp.spec.ts +275 -0
- package/app/components/05.forms/input-otp/tests/InputOtpField.spec.ts +76 -0
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +151 -33
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +11 -3
- package/app/composables/useGoogleReviews.ts +4 -0
- package/app/types/components/google-reviews.d.ts +18 -0
- package/app/types/components/index.ts +1 -0
- package/nuxt.config.ts +9 -9
- package/package.json +9 -8
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
This layer includes light/dark/auto colour scheme
|
|
5
|
+
This layer includes light/dark/auto colour scheme switching (an attribute, not `light-dark()`, for older iPad Safari). The layer ships light colour values only; dark values are the app's to add (`theming-dark-mode.md`). By default it:
|
|
6
6
|
|
|
7
7
|
1. Injects a synchronous `<head>` script to apply the saved scheme before first paint (FOUC prevention)
|
|
8
8
|
2. Provides `useColourScheme()` composable to read/write the active scheme via localStorage and `document.documentElement.dataset.colorScheme`
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Component compliance checklist
|
|
2
|
+
|
|
3
|
+
The definition of done for every component in this library. It is shared by two commands:
|
|
4
|
+
|
|
5
|
+
- `/new-component` uses it as the build spec for a component that doesn't exist yet.
|
|
6
|
+
- `/migrate-component` uses it to bring an existing component up to standard.
|
|
7
|
+
|
|
8
|
+
Item numbers are stable and are referenced elsewhere ("checklist item 4", the ledger columns, memory
|
|
9
|
+
notes), so add new items at the end rather than renumbering.
|
|
10
|
+
|
|
11
|
+
Go through every item. Skip one only when it genuinely doesn't apply, and say why briefly; don't
|
|
12
|
+
skip silently. Notes marked **Existing component** only apply when migrating.
|
|
13
|
+
|
|
14
|
+
A component passes when its Component Ledger row (`node .claude/component-ledger/build.mjs`, then
|
|
15
|
+
`.claude/component-ledger/audit.json`) shows `score: 5`, a real tier, and `variants`, `legacy_props`,
|
|
16
|
+
`story_args_bug`, `eslint_issues`, `redundant_priv_tokens` and `styling_doc_outdated` all `false`.
|
|
17
|
+
The ledger can't see items 9, 10, 11 and 14, so check those by hand.
|
|
18
|
+
|
|
19
|
+
## The checklist
|
|
20
|
+
|
|
21
|
+
1. **Tier folder** — the component lives in one of `01.atoms`–`05.forms` (see
|
|
22
|
+
`project_component_tiers` in memory for what each tier means), in its own kebab-case folder:
|
|
23
|
+
`app/components/<tier>/<family>/`. If none fits cleanly, ask the user rather than guessing.
|
|
24
|
+
**Existing component:** if it lives outside a tier, move it with `git mv`.
|
|
25
|
+
2. **Props pattern** — `interface Props` + `withDefaults(defineProps<Props>(), {...})`, never
|
|
26
|
+
options-style `defineProps({...})`. Include `styleClassPassthrough` and wire it through
|
|
27
|
+
`useStyleClassPassthrough()`. Consumer-facing data-shape types go in
|
|
28
|
+
`app/types/components/<component-name>.d.ts` and are exported from `app/types/components/index.ts`
|
|
29
|
+
(see `component-export-types.md`), not left inline in the `.vue` file.
|
|
30
|
+
3. **CSS tokens** — audit every custom property in the component's `<style>` block against the
|
|
31
|
+
rule in CLAUDE.md's "Public token pattern" (consumer relevance, not reuse count):
|
|
32
|
+
- A value a consumer could plausibly want to override — including a per-state variant like a
|
|
33
|
+
hover-only width or a dark-theme colour — must be a **public** token (`--component-name-x`),
|
|
34
|
+
consumed directly at its point of use, even if used only once.
|
|
35
|
+
- A value with no plausible consumer relevance stays **private** (`--_x`), even if used only
|
|
36
|
+
once — don't strip the prefix just because reuse is low.
|
|
37
|
+
- Fix the actual bug class this catches: a consumer-relevant value that's currently private
|
|
38
|
+
with no public fallback. That's the thing to promote, not every `--_` var you find.
|
|
39
|
+
- Remove redundant private tokens (the other half of pitfall #20): a `--_` token declared but
|
|
40
|
+
never read, or one whose whole value is a single `var(--public-token, fallback)` and which is
|
|
41
|
+
read exactly once, is pure duplication. Delete the unread ones; inline the public token at
|
|
42
|
+
the single point of use for the rest. Keep a private token that's read in more than one
|
|
43
|
+
place, is composed (`calc()`, `v-bind()`, several `var()`s), or is re-declared by a
|
|
44
|
+
state/variant selector. `node .claude/component-ledger/fix-private-tokens.mjs <file.vue>`
|
|
45
|
+
lists them (dry run); `--write` applies the ones marked `fix`. A `REVIEW` item is declared
|
|
46
|
+
somewhere other than the root rule or read outside its subtree: check the element that
|
|
47
|
+
reads it sits inside the one that declares it before adding `--include-review`, or fix it by
|
|
48
|
+
hand. Afterwards, update any CONSUMER-STYLING.md or skill doc line that names the removed
|
|
49
|
+
tokens, and delete comments left describing them.
|
|
50
|
+
- Colours default from `--theme-*` tokens through the component's own public tokens, never by
|
|
51
|
+
reading a global `--theme-*` token bare at the point of use (pitfall #14). Pair a dark/bold
|
|
52
|
+
surface with `--theme-on-surface`, not `--theme-text` (pitfall #13). For a card or panel
|
|
53
|
+
background default to `--theme-surface-subtle` (the surface `--theme-text` sits on);
|
|
54
|
+
`--theme-surface` and `--theme-border` are bold steps meant for buttons and focus, and make a
|
|
55
|
+
card a solid block of theme colour.
|
|
56
|
+
- Style rules sit in `@layer components`, with child selectors nested inside the root block
|
|
57
|
+
using native nesting (`& .block__child`), never Sass `&__child` concatenation
|
|
58
|
+
(`css-nesting-conventions.md`).
|
|
59
|
+
4. **CONSUMER-STYLING.md** — create or update so every public token the component exposes is
|
|
60
|
+
documented, with its default. Skip only if the component genuinely has no override surface at
|
|
61
|
+
all (no CSS custom properties, no class passthrough). Use this fixed layout, in this order:
|
|
62
|
+
- `## Public token API` — token tables (split into `###` groups by element when there are
|
|
63
|
+
several), each token with its default; list any private `--_` tokens as "not public API", or
|
|
64
|
+
say there are none.
|
|
65
|
+
- `## State hooks` — `data-*` attributes / state selectors and the inner element class names.
|
|
66
|
+
Omit only if the component has neither.
|
|
67
|
+
- Component-specific reference sections (motion, sizing model, etc.) may follow here.
|
|
68
|
+
- `## Global theming` — optional. Include a `:where(html) { ... }` block only when there's
|
|
69
|
+
something worth showing (e.g. a common site-wide tweak); don't pad it out.
|
|
70
|
+
- `## Local overrides` — required heading (the ledger's `styling_doc_outdated` check looks for
|
|
71
|
+
it). The mechanics are the same for every component and live in
|
|
72
|
+
`.claude/skills/component-local-style-override.md`, so don't write bespoke examples by
|
|
73
|
+
default. Write one standard paragraph (copy it from any migrated doc, e.g.
|
|
74
|
+
`05.forms/form-field/CONSUMER-STYLING.md`): set tokens on an element you own (page/section
|
|
75
|
+
class, or a `:style-class-passthrough` class, or a plain `class` that falls through when there's
|
|
76
|
+
no passthrough prop); keep the block unlayered; from a **consumer's** `<style scoped>` file,
|
|
77
|
+
tokens on their own element still work but selectors reaching inside need `:deep()` (this
|
|
78
|
+
library's own styles are never scoped); link the guide. Then add a **Caveat** only where this
|
|
79
|
+
component breaks that model, most often a token it re-declares on its own elements (so an
|
|
80
|
+
ancestor value never lands, e.g. `DisplayThemeSwitch`'s sizing tokens, `ServiceSummary`'s
|
|
81
|
+
`--display-pill-*`, `InputError`'s `data-theme`), or a wrapper that doesn't forward
|
|
82
|
+
passthrough. Check with a grep for `^\s*--<name>-[\w-]+\s*:` in the component's `<style>`.
|
|
83
|
+
Existing examples can stay as `###` subsections (`Page or section`, `One instance`).
|
|
84
|
+
- `## Recipe: <name>` — optional, for component-specific patterns that need several tokens
|
|
85
|
+
working together (e.g. InputDescription's panel/callout).
|
|
86
|
+
- `## Class passthrough` — what `style-class-passthrough` targets and whether it's actually
|
|
87
|
+
reachable in normal use.
|
|
88
|
+
- `## Notes` — optional, last.
|
|
89
|
+
|
|
90
|
+
Dated "Changed YYYY-MM-DD" migration notes go as blockquotes inside the relevant section.
|
|
91
|
+
5. **Tests** — create or bring up to date in `tests/`, following the Testing Requirements section
|
|
92
|
+
of CLAUDE.md (mountSuspended, fake-timer rules, etc.). Skip only for a trivial presentational
|
|
93
|
+
component with no logic worth testing — say so explicitly if you skip. If anything is derived
|
|
94
|
+
from slots, test it by toggling a `v-if`'d slot in a host component after mount (pitfall #25).
|
|
95
|
+
6. **Storybook story** — create or update `stories/*.stories.ts` with controls for props/slots
|
|
96
|
+
that warrant them (see `.claude/skills/storybook-add-story.md`). The story title uses the tier
|
|
97
|
+
name directly. A component whose layout responds to its container gets the `CanvasSwitcher`
|
|
98
|
+
decorator (see `PageRow.stories.ts` or `GoogleReviews.stories.ts`, which also shows a per-story
|
|
99
|
+
starting canvas via `parameters.initialCanvas`) rather than a hand-sized wrapper or a width arg.
|
|
100
|
+
6a. **Storybook Controls-panel reactivity** — check every story `Template`/`render` function in
|
|
101
|
+
the component's `stories/*.stories.ts` files for this bug: `@storybook/vue3` mounts the story
|
|
102
|
+
component **once** and, on every Controls-panel change, mutates the same reactive `args`
|
|
103
|
+
object in place — it never re-runs `setup()`. A story that destructures or spreads `args` into
|
|
104
|
+
local variables/a ref at setup-time (the tell-tale shape: `const { modelValue, ...otherArgs }
|
|
105
|
+
= args;`, whether or not it's then fed into a `ref()`) takes a one-time snapshot, so most
|
|
106
|
+
Controls silently stop updating the rendered story after first render — often alongside a
|
|
107
|
+
prop-name typo in `argTypes` that compounds it (e.g. a control literally named differently
|
|
108
|
+
from the real component prop it's meant to drive — check that too). Fix by binding the
|
|
109
|
+
template directly to the live object (`v-model="args.modelValue"`, `v-bind="componentArgs"`
|
|
110
|
+
where `componentArgs` is a `computed()` that strips only genuinely non-prop extra args) rather
|
|
111
|
+
than copying values out in `setup()` — a destructure/spread from `args` is only safe when it's
|
|
112
|
+
inside a `computed()`. The ledger's `story_args_bug` column flags this automatically (any
|
|
113
|
+
`{...} = args;` spread not wrapped in a `computed()`); still eyeball each story file yourself,
|
|
114
|
+
since a differently-shaped variant of the same mistake may not match that exact heuristic.
|
|
115
|
+
6b. **Stress-test story** — add a `StressTest` story ("Stress Test (Worst-Case Data)") that
|
|
116
|
+
feeds the component the most hostile data a QA tester could think of, and fix whatever it
|
|
117
|
+
breaks. Go through every prop, slot and data field and ask what would break it, for example:
|
|
118
|
+
- very long text, and long **unbroken** strings (no spaces, a long URL) in every text field,
|
|
119
|
+
including labels and copy props (long translated copy, e.g. German)
|
|
120
|
+
- empty strings, missing optional fields, a single character, a single item, the maximum
|
|
121
|
+
number of items
|
|
122
|
+
- emoji (including at the start of anything that takes initials or first letters), HTML-like
|
|
123
|
+
text (must render as text), right-to-left text
|
|
124
|
+
- out-of-range or odd numbers (0, negative, above the maximum, fractional, very large)
|
|
125
|
+
- broken image or link URLs
|
|
126
|
+
Describe what to check in the story's docs description, and look at it at every
|
|
127
|
+
`CanvasSwitcher` width. Fixes usually mean `overflow-wrap: anywhere`, `min-inline-size: 0` on
|
|
128
|
+
flex/grid children, `Array.from(str)` instead of `str[0]`, and `clamp()` on values
|
|
129
|
+
derived from data. Give each text element a consumer might want to shorten (names, titles,
|
|
130
|
+
descriptions, body text) a line-clamp token, `--<component>-<element>-line-clamp`, which
|
|
131
|
+
covers ellipsis too (`1` is single-line ellipsis). Default it to `none` unless the layout
|
|
132
|
+
clearly needs a cap, and never clamp text that must stay readable (legal or attribution text,
|
|
133
|
+
link text that names its destination). Shape and rules: `theming-component-token-pattern.md`,
|
|
134
|
+
"Line-clamp tokens". Add a unit test for any logic fix. A component that takes no data or copy
|
|
135
|
+
(e.g. a pure layout wrapper) still gets one with oversized slot content.
|
|
136
|
+
7. **Skill doc** — create or update `.claude/skills/components/<component-name>.md`, following the
|
|
137
|
+
pattern of an existing one. A labelled `<Name>Field` wrapper (or any other wrapper) is documented
|
|
138
|
+
in its control's doc under a "Variants" section, not in a doc of its own
|
|
139
|
+
(`feedback_variants_deprecated_as_own_skill_docs`). **Existing component:** if you find a
|
|
140
|
+
`variants/` subfolder, flatten it into the parent folder (`git mv` the `.vue` files plus their
|
|
141
|
+
stories/tests up one level, fix relative imports); `pathPrefix: false` keeps the auto-import
|
|
142
|
+
names unchanged. All existing `variants/` folders were flattened 2026-09-25.
|
|
143
|
+
8. **VS Code snippet** — create or update `.vscode/srcdev-component-{family}.code-snippets`,
|
|
144
|
+
covering the control and its Field wrapper in one file.
|
|
145
|
+
9. **Accessibility** — check for gaps beyond what already-passing tests would catch:
|
|
146
|
+
- Interactive controls (buttons, toggles, custom form-like widgets) have an accessible name —
|
|
147
|
+
visible text, `aria-label`, or `aria-labelledby`.
|
|
148
|
+
- Keyboard behaviour matches what's actually implemented — no keydown case that only calls
|
|
149
|
+
`event.preventDefault()` with a comment like "could add this later", and no screen-reader
|
|
150
|
+
copy (visible or `sr-only`) that describes an interaction the component doesn't actually
|
|
151
|
+
perform. Cross-check every claim in the copy against the handler that's supposed to back it.
|
|
152
|
+
- Focus is visible (`:focus-visible`, not a suppressed outline) on anything focusable.
|
|
153
|
+
- Live-updating or auto-advancing content (carousels, marquees, auto-dismissing toasts) has a
|
|
154
|
+
way to pause it, per WCAG 2.2.2 — a visible control, or pause-on-hover/focus at minimum.
|
|
155
|
+
- `prefers-reduced-motion` is respected for any animation that isn't purely decorative.
|
|
156
|
+
- A component with a `tag` prop that can render a landmark uses `useAriaLabelledById`
|
|
157
|
+
(`component-aria-landmark.md`), unless it falls back to `aria-label` when no heading is
|
|
158
|
+
given: the composable can't do that and warns whenever a landmark has no heading. Then label
|
|
159
|
+
it directly with `useId()` (`aria-labelledby` when the heading slot is filled, `aria-label`
|
|
160
|
+
otherwise, both decided by a function called from the template, per pitfall #25), as
|
|
161
|
+
`GoogleReviews` does.
|
|
162
|
+
10. **Localisation (no hardcoded consumer-facing text)** — grep the template and script for
|
|
163
|
+
user-visible string literals (button copy, `aria-label`/`aria-description` text, placeholder
|
|
164
|
+
text, empty-state messages, etc.) that aren't already props. This library has no i18n
|
|
165
|
+
framework dependency (see `MarqueeScroller`'s `playLabel`/`pauseLabel`/`ariaLabel`/
|
|
166
|
+
`ariaDescription` for the pattern) — promote any hardcoded copy to a string prop with the
|
|
167
|
+
existing English text as its default, so a consumer can pass translated strings from their own
|
|
168
|
+
i18n solution. Icon-only controls should also get an icon-override prop or slot (see
|
|
169
|
+
`playIcon`/`pauseIcon`/`toggle-icon`) alongside the label prop, not just the label. Default
|
|
170
|
+
copy follows the em dash ban for visitor-facing text.
|
|
171
|
+
11. **No `light-dark()` CSS function in the component's own values** — grep the `<style>` block
|
|
172
|
+
(and any `v-bind()`-fed script constants) for `light-dark(`. Older iPads/Safari versions don't
|
|
173
|
+
support it, so this library's own default values must never rely on it — use the existing
|
|
174
|
+
`--theme-*` token convention (pitfall #14) where one already fits. Where none fits and you're
|
|
175
|
+
replacing a literal `light-dark(light-value, dark-value)` call directly, keep the **light**
|
|
176
|
+
value only — don't invent a dark-mode fallback scheme, and don't ask which of the two to keep.
|
|
177
|
+
The mechanism stays available for consumers: a consumer app is free to define its own public
|
|
178
|
+
token overrides using `light-dark()` inside its own CSS, since that's their own browser-support
|
|
179
|
+
decision to make, not this library's default. The global token layer (`03.theming`, `a11y/_variables.css`) is light-only too since 2026-10-04, so `--theme-*` tokens are safe to consume.
|
|
180
|
+
12. **Add the component to the Migrated Fields Form story** — once the component reaches 5/5,
|
|
181
|
+
add one field for it to
|
|
182
|
+
`app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts` (import the
|
|
183
|
+
component, add a field to the demo form, wire minimal state/error handling matching the
|
|
184
|
+
existing fields). This story is a living migration-progress tracker, not just a demo — its own
|
|
185
|
+
top-of-file comment lists which components are currently included and must be updated too
|
|
186
|
+
(add the new component to the list, bump the "As of `<date>`" note). Skip only if the
|
|
187
|
+
component's shape genuinely doesn't fit a form field (anything outside `05.forms`, or a
|
|
188
|
+
non-input `05.forms` helper component) — state why if you skip.
|
|
189
|
+
13. **Naming** — bare control `<Name>`, labelled wrapper `<Name>Field`, root DOM class following
|
|
190
|
+
the name (`.input-number`, `.input-number-field`), per `.claude/skills/component-naming.md`.
|
|
191
|
+
Multi-word, never a native or plausibly-future HTML element name, and not a bare common word
|
|
192
|
+
a consuming app is likely to define itself (the Nuxt-layer collision caveat).
|
|
193
|
+
**Existing component:** if it (or its wrappers) still uses a `Core`, `Default` or `WithLabel`
|
|
194
|
+
suffix, rename it. The doc's backlog table gives the proposed name and which consumer repos
|
|
195
|
+
use the old one. Rows marked **decide** (collision-prone generic names like `Card`/`Tabs`)
|
|
196
|
+
need an `AskUserQuestion` before renaming. Follow the doc's rename procedure (including
|
|
197
|
+
updating consumer repos) and tick the row off. Do this as the last checklist step, so the
|
|
198
|
+
earlier steps' edits don't have to track a mid-flight rename.
|
|
199
|
+
14. **Known pitfalls** — check the component against the CLAUDE.md pitfalls that recur in new
|
|
200
|
+
code (the ledger can't see any of these):
|
|
201
|
+
- Every structural class name is prefixed with the component name (`.display-dialog-header`,
|
|
202
|
+
not `.header`), since the component renders inside the consumer's DOM (pitfall #16).
|
|
203
|
+
- A `position: fixed`/`sticky` overlay exposes a public z-index token defaulting to `999999`
|
|
204
|
+
(pitfall #17).
|
|
205
|
+
- No utility class from `06.utility-classes` in the component's own markup unless it's used
|
|
206
|
+
for what that class actually means (pitfall #18, `mi-12`).
|
|
207
|
+
- Nothing load-bearing relies on a CSS feature WebKit lacks (pitfall #19).
|
|
208
|
+
- No `computed()` over `useSlots()`; use a function called from the template (pitfall #25).
|
|
209
|
+
- Values derived from props are `computed()`, not plain consts frozen at mount.
|
|
210
|
+
- Explicit closing tags (no self-closing non-void elements), hyphenated props in templates, no
|
|
211
|
+
manual Vue imports in components.
|
|
212
|
+
- `NuxtImg` gets explicit `width` and `height`; a `<video>` uses a `<source :src>` child with
|
|
213
|
+
`:key` and `.load()` (pitfall #12).
|
|
214
|
+
- No `#components` import (breaks Storybook); use `resolveComponent()`.
|
|
@@ -62,8 +62,8 @@ Target `.my-action-button.input-button` inside the wrapper to override InputButt
|
|
|
62
62
|
|
|
63
63
|
/* Use private tokens only for new semantic states with no theme equivalent */
|
|
64
64
|
&.some-state {
|
|
65
|
-
--_state-surface:
|
|
66
|
-
--_state-text:
|
|
65
|
+
--_state-surface: var(--green-01);
|
|
66
|
+
--_state-text: var(--green-08);
|
|
67
67
|
|
|
68
68
|
.my-action-button.input-button {
|
|
69
69
|
background-color: var(--_state-surface);
|
|
@@ -90,10 +90,10 @@ See [CONSUMER-STYLING.md](../../app/components/03.organisms/image-galleries/caro
|
|
|
90
90
|
| `--carousel-flip-item-max-width` | `80rem` | Max width of a single item, before subtracting the edge-preview width |
|
|
91
91
|
| `--carousel-flip-edge-preview-width` | `4rem` | How much of the neighbouring items peek in at each edge |
|
|
92
92
|
| `--carousel-flip-marker-gap` | `1rem` | Gap between marker (dot) buttons |
|
|
93
|
-
| `--carousel-flip-marker-active-colour` | `
|
|
94
|
-
| `--carousel-flip-button-background-colour` | `
|
|
93
|
+
| `--carousel-flip-marker-active-colour` | `var(--slate-10)` | Active marker background colour |
|
|
94
|
+
| `--carousel-flip-button-background-colour` | `white` | Prev/next button background colour |
|
|
95
95
|
| `--carousel-flip-button-border-width` | `0.1rem` | Prev/next button border width |
|
|
96
|
-
| `--carousel-flip-button-border-colour` | `
|
|
96
|
+
| `--carousel-flip-button-border-colour` | `hsl(0, 29%, 3%)` | Prev/next button border colour |
|
|
97
97
|
| `--carousel-flip-button-border-radius` | `100vw` | Prev/next button corner radius |
|
|
98
98
|
| `--carousel-flip-button-padding` | `0.8rem` | Prev/next button padding |
|
|
99
99
|
| `--carousel-flip-button-icon-size` | `2.4rem` | Prev/next button icon glyph size |
|
|
@@ -46,8 +46,8 @@ see CONSUMER-STYLING.md for why these can't be resized via CSS.
|
|
|
46
46
|
|
|
47
47
|
| Property | Default | Controls |
|
|
48
48
|
|----------|---------|----------|
|
|
49
|
-
| `--clipped-panel-background-colour` | `
|
|
50
|
-
| `--clipped-panel-outline-colour` | `
|
|
49
|
+
| `--clipped-panel-background-colour` | `hsl(0, 0%, 96%)` | Panel background |
|
|
50
|
+
| `--clipped-panel-outline-colour` | `hsl(0, 29%, 3%)` | Outline colour and text/icon colour |
|
|
51
51
|
| `--clipped-panel-outline-width` | `1px` | Outline width |
|
|
52
52
|
|
|
53
53
|
## Notes
|
|
@@ -59,10 +59,10 @@ See [CONSUMER-STYLING.md](../../app/components/01.atoms/content-wrappers/layout-
|
|
|
59
59
|
| `--dashboard-quad-grid-gap` | `2rem` | Gap between the four slot panels |
|
|
60
60
|
| `--dashboard-quad-grid-padding` | `2rem` | Padding inside each slot panel |
|
|
61
61
|
| `--dashboard-quad-grid-outline-width` | `0.1rem` | Slot panel outline width |
|
|
62
|
-
| `--dashboard-quad-grid-outline-colour` | `
|
|
62
|
+
| `--dashboard-quad-grid-outline-colour` | `black` | Slot panel outline colour |
|
|
63
63
|
| `--dashboard-quad-grid-outline-offset` | `0` | Slot panel outline offset |
|
|
64
64
|
| `--dashboard-quad-grid-border-width` | `0` | Slot panel border width (off by default — the panel edge is drawn by the outline) |
|
|
65
|
-
| `--dashboard-quad-grid-border-colour` | `
|
|
65
|
+
| `--dashboard-quad-grid-border-colour` | `black` | Slot panel border colour, only visible once `--dashboard-quad-grid-border-width` is set |
|
|
66
66
|
| `--dashboard-quad-grid-border-radius` | `0.5rem` | Slot panel corner radius |
|
|
67
67
|
| `--dashboard-quad-grid-rule-width` | `0.1rem` | Width of the grid gap rule line (CSS Gap Decorations, limited browser support) |
|
|
68
68
|
| `--dashboard-quad-grid-rule-colour` | `transparent` | Colour of the grid gap rule line, invisible by default |
|
|
@@ -67,7 +67,7 @@ See [CONSUMER-STYLING.md](../../app/components/01.atoms/content-wrappers/layout-
|
|
|
67
67
|
| `--dashboard-stats-grid-gap` | `1.2rem` | Gap between panels, in every row |
|
|
68
68
|
| `--dashboard-stats-grid-panel-padding` | `1.2rem` | Padding inside each panel |
|
|
69
69
|
| `--dashboard-stats-grid-border-width` | `0.1rem` | Panel border width |
|
|
70
|
-
| `--dashboard-stats-grid-border-colour` | `
|
|
70
|
+
| `--dashboard-stats-grid-border-colour` | `hsl(0, 29%, 3%)` | Panel border colour |
|
|
71
71
|
| `--dashboard-stats-grid-border-radius` | `1.2rem` | Panel corner radius |
|
|
72
72
|
| `--dashboard-stats-grid-rule-width` | `0.1rem` | Width of the grid gap rule line (CSS Gap Decorations, limited browser support) |
|
|
73
73
|
| `--dashboard-stats-grid-rule-colour` | `transparent` | Colour of the grid gap rule line, invisible by default |
|
|
@@ -59,7 +59,7 @@ const navLinks: ResponsiveHeaderNavItem[] = [
|
|
|
59
59
|
| `--deep-expanding-menu-classic-item-gap` | `2.4rem` | Gap between top-level items |
|
|
60
60
|
| `--deep-expanding-menu-classic-link-border-width` | `0.2rem` | Bottom border width on links/toggles |
|
|
61
61
|
| `--deep-expanding-menu-classic-link-padding-block` | `0.8rem` | Vertical padding on links/toggles |
|
|
62
|
-
| `--deep-expanding-menu-classic-link-border-colour-hover` | `
|
|
62
|
+
| `--deep-expanding-menu-classic-link-border-colour-hover` | `var(--blue-10)` | Bottom border colour on hover/focus |
|
|
63
63
|
| `--deep-expanding-menu-classic-icon-size` | `1.2rem` | Caret icon size |
|
|
64
64
|
| `--deep-expanding-menu-classic-panel-offset-top` | `4rem` | Panel offset from the toggle |
|
|
65
65
|
| `--deep-expanding-menu-classic-panel-width` | `20rem` | Panel width below `768px` |
|
|
@@ -61,7 +61,7 @@ const navLinks: ResponsiveHeaderNavItem[] = [
|
|
|
61
61
|
| `--deep-expanding-menu-gap` | `2.4rem` | Gap between top-level items |
|
|
62
62
|
| `--deep-expanding-menu-link-border-width` | `0.2rem` | Bottom border width on links/toggles |
|
|
63
63
|
| `--deep-expanding-menu-link-padding-block` | `0.8rem` | Vertical padding on links/toggles |
|
|
64
|
-
| `--deep-expanding-menu-link-border-colour-hover` | `
|
|
64
|
+
| `--deep-expanding-menu-link-border-colour-hover` | `var(--blue-10)` | Bottom border colour on hover/focus |
|
|
65
65
|
| `--deep-expanding-menu-icon-size` | `1.2rem` | Caret icon size |
|
|
66
66
|
| `--deep-expanding-menu-panel-width` | `min(100%, 50vw)` | Popover panel width |
|
|
67
67
|
| `--deep-expanding-menu-panel-background-colour` | `white` | Popover panel background |
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: GoogleReviews
|
|
3
|
+
description: GoogleReviews + GoogleReviewCard — Google Places reviews in a single scrolling row; server route, env vars, useGoogleReviews composable, props, slots, tokens, Google terms
|
|
4
|
+
type: reference
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# GoogleReviews
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
`GoogleReviews` (`03.organisms/google-reviews`) shows a place's Google reviews as a single
|
|
12
|
+
horizontally scrolling row of cards, with an overall rating summary, prev/next buttons and the
|
|
13
|
+
Google Maps attribution. It retrieves nothing itself: the layer's server route fetches the data
|
|
14
|
+
with a server-only API key, `useGoogleReviews()` reads that route, and the component displays
|
|
15
|
+
what it's given.
|
|
16
|
+
|
|
17
|
+
Google returns **at most 5 reviews** per place. Showing more needs the Business Profile API with
|
|
18
|
+
the owner signed in, which this doesn't support.
|
|
19
|
+
|
|
20
|
+
## Setup (consumer app)
|
|
21
|
+
|
|
22
|
+
1. In Google Cloud, enable **Places API (New)**, create an API key and restrict it to that API.
|
|
23
|
+
The `reviews` field is billed at the Place Details Enterprise + Atmosphere rate; the route
|
|
24
|
+
caches responses to limit calls.
|
|
25
|
+
2. Set env vars (server-only, never sent to the browser):
|
|
26
|
+
|
|
27
|
+
| Env var | Purpose |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `NUXT_GOOGLE_REVIEWS_API_KEY` | Places API key (required) |
|
|
30
|
+
| `NUXT_GOOGLE_REVIEWS_PLACE_ID` | The business's place ID (required; find it with Google's Place ID Finder) |
|
|
31
|
+
| `NUXT_GOOGLE_REVIEWS_LANGUAGE_CODE` | Optional, e.g. `en-GB` |
|
|
32
|
+
| `NUXT_GOOGLE_REVIEWS_CACHE_MAX_AGE` | Seconds to cache the response, default `3600` |
|
|
33
|
+
|
|
34
|
+
3. Use it:
|
|
35
|
+
|
|
36
|
+
```vue
|
|
37
|
+
<script setup lang="ts">
|
|
38
|
+
const { data } = await useGoogleReviews();
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<GoogleReviews :data="data" />
|
|
43
|
+
</template>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The place ID is read only from server config, never from the request, so the route can't be used
|
|
47
|
+
to look up other places on your key. The layer adds `lh3.googleusercontent.com` (reviewer photos)
|
|
48
|
+
to `image.domains`.
|
|
49
|
+
|
|
50
|
+
## Server route and composable
|
|
51
|
+
|
|
52
|
+
- `GET /api/google-reviews` (`server/api/google-reviews.get.ts`) returns `GoogleReviewsData`,
|
|
53
|
+
cached with Nitro's `defineCachedEventHandler` for `cacheMaxAge` seconds (stale-while-revalidate).
|
|
54
|
+
- Missing key or place ID: `500` whose message names the missing env var. Google request fails:
|
|
55
|
+
`502`.
|
|
56
|
+
- `useGoogleReviews()` is `useFetch<GoogleReviewsData>("/api/google-reviews")`, so it returns
|
|
57
|
+
`{ data, status, error, refresh }`. Use `status`/`error` for your own fallback; the component
|
|
58
|
+
renders nothing without data.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import type { GoogleReviewsData, GoogleReview } from "srcdev-nuxt-components";
|
|
62
|
+
|
|
63
|
+
interface GoogleReview {
|
|
64
|
+
authorName: string;
|
|
65
|
+
authorUri?: string;
|
|
66
|
+
authorPhotoUri?: string;
|
|
67
|
+
rating: number;
|
|
68
|
+
text: string;
|
|
69
|
+
relativeTime: string; // "2 weeks ago", in the configured language
|
|
70
|
+
publishTime: string; // ISO timestamp
|
|
71
|
+
reviewUri?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface GoogleReviewsData {
|
|
75
|
+
placeName: string;
|
|
76
|
+
rating: number;
|
|
77
|
+
totalReviews: number;
|
|
78
|
+
mapsUri: string;
|
|
79
|
+
reviews: GoogleReview[];
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Props
|
|
84
|
+
|
|
85
|
+
| Prop | Type | Default | Notes |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `data` | `GoogleReviewsData \| null` | required | Usually `data` from `useGoogleReviews()` |
|
|
88
|
+
| `minRating` | `number` (0–5) | `0` | Hides lower-rated reviews and shows `filterNotice` |
|
|
89
|
+
| `showSummary` | `boolean` | `true` | Overall rating, stars and total count linked to the Maps listing |
|
|
90
|
+
| `tag` | `"section" \| "div"` | `"section"` | Root element |
|
|
91
|
+
| `ariaLabel` | `string` | `"Google reviews"` | Root label when there's no `heading` slot |
|
|
92
|
+
| `prevLabel` / `nextLabel` | `string` | `"Previous reviews"` / `"Next reviews"` | Button accessible names |
|
|
93
|
+
| `prevIcon` / `nextIcon` | `string` | `ic:outline-keyboard-arrow-left/right` | Button icons |
|
|
94
|
+
| `ratingLabel` | `string` | `"Rated {rating} out of 5"` | Screen-reader rating text |
|
|
95
|
+
| `totalLabel` | `string` | `"{count} reviews on Google"` | Summary link text |
|
|
96
|
+
| `readMoreLabel` | `string` | `"Read on Google"` | Per-card link text |
|
|
97
|
+
| `filterNotice` | `string` | `"Showing reviews rated {rating} stars and above, in Google's order."` | Shown when `minRating > 0` |
|
|
98
|
+
| `attributionText` | `string` | `"Reviews from Google Maps"` | Keep "Google Maps" when translating |
|
|
99
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | Classes on the root |
|
|
100
|
+
|
|
101
|
+
All copy props are there for localisation; pass translated strings. `{rating}` and `{count}` are
|
|
102
|
+
replaced.
|
|
103
|
+
|
|
104
|
+
## Slots
|
|
105
|
+
|
|
106
|
+
| Slot | Props | Notes |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `heading` | `{ headingId }` | Heading above the summary. Bind `headingId` to it: the root is then labelled by the heading instead of `ariaLabel` |
|
|
109
|
+
| `card` | `{ review }` | Replaces each card. The list, scrolling and attribution stay. Keep the author attribution (name, photo, profile link) if you replace it |
|
|
110
|
+
|
|
111
|
+
## Behaviour
|
|
112
|
+
|
|
113
|
+
- Renders nothing when `data` is null or no reviews are left after filtering.
|
|
114
|
+
- The summary shows the overall rating (one decimal), stars, and the total count linked to the
|
|
115
|
+
Google Maps listing.
|
|
116
|
+
- Each card shows the reviewer's avatar (photo, or initials), name linked to their Google profile,
|
|
117
|
+
star rating, relative date, and the review text clamped to 5 lines, with a "Read on Google" link
|
|
118
|
+
to the full review.
|
|
119
|
+
- The Google Maps attribution is always shown.
|
|
120
|
+
- Cards sit in a single row that never wraps and scrolls horizontally with snapping, by touch,
|
|
121
|
+
trackpad or keyboard (the row is focusable; arrow keys scroll it, and tabbing to a link scrolls
|
|
122
|
+
its card into view).
|
|
123
|
+
- Prev/next scroll by one card, are disabled (`aria-disabled`, still focusable) at either end, and
|
|
124
|
+
are hidden when every card fits. Direction follows `dir="rtl"`.
|
|
125
|
+
- Card sections line up across the row (name, date, stars, text and link each share a row, via
|
|
126
|
+
subgrid), even when one reviewer's name or date wraps further than the others.
|
|
127
|
+
- Smooth scrolling is turned off under `prefers-reduced-motion`.
|
|
128
|
+
- The root is a labelled region; reviews are a `ul`/`li` list. Stars are hidden from screen
|
|
129
|
+
readers and replaced by `ratingLabel` text; the avatar is hidden too, since the name is read out.
|
|
130
|
+
Each "Read on Google" link is described by its author's name.
|
|
131
|
+
- `minRating` hides lower-rated reviews and shows the filter notice; no notice without a filter.
|
|
132
|
+
- Survives hostile data (see the `StressTest` story): long and unbroken text wraps inside its card,
|
|
133
|
+
reviewer names are cut to 2 lines (`--google-reviews-author-line-clamp`),
|
|
134
|
+
initials take whole characters (emoji-safe), star fill is clamped to 0–5, review text renders as
|
|
135
|
+
plain text. A broken photo URL still shows the browser's broken-image icon, since
|
|
136
|
+
`DisplayAvatar` has no image-error fallback.
|
|
137
|
+
|
|
138
|
+
## Variants
|
|
139
|
+
|
|
140
|
+
### GoogleReviewCard
|
|
141
|
+
|
|
142
|
+
One review, used by `GoogleReviews` and usable on its own (e.g. in a custom layout via the `card`
|
|
143
|
+
slot).
|
|
144
|
+
|
|
145
|
+
| Prop | Type | Default |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `review` | `GoogleReview` | required |
|
|
148
|
+
| `tag` | `"article" \| "div"` | `"article"` |
|
|
149
|
+
| `ratingLabel` | `string` | `"Rated {rating} out of 5"` |
|
|
150
|
+
| `readMoreLabel` | `string` | `"Read on Google"` |
|
|
151
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` |
|
|
152
|
+
|
|
153
|
+
```vue
|
|
154
|
+
<GoogleReviews :data="data">
|
|
155
|
+
<template #card="{ review }">
|
|
156
|
+
<GoogleReviewCard :review="review" tag="div" :style-class-passthrough="['my-review']" />
|
|
157
|
+
</template>
|
|
158
|
+
</GoogleReviews>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## CSS custom properties
|
|
162
|
+
|
|
163
|
+
All `--google-reviews-*`, shared by both components. Full list with defaults in
|
|
164
|
+
`app/components/03.organisms/google-reviews/CONSUMER-STYLING.md`. Most used:
|
|
165
|
+
|
|
166
|
+
```css
|
|
167
|
+
.reviews-section {
|
|
168
|
+
--google-reviews-card-width: min(28rem, 80%);
|
|
169
|
+
--google-reviews-card-surface: var(--slate-01);
|
|
170
|
+
--google-reviews-card-border-radius: 1.6rem;
|
|
171
|
+
--google-reviews-star-colour: var(--amber-06);
|
|
172
|
+
--google-reviews-text-line-clamp: 4;
|
|
173
|
+
--google-reviews-author-line-clamp: 1;
|
|
174
|
+
--google-reviews-scroll-padding: 1.6rem;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Google's terms
|
|
179
|
+
|
|
180
|
+
- Credit each review's author: the name, photo and profile link are rendered by default; keep them
|
|
181
|
+
if you use the `card` slot.
|
|
182
|
+
- Keep the Google Maps attribution visible and readable.
|
|
183
|
+
- Any filtering needs a visible notice describing it (`filterNotice`, shown automatically with
|
|
184
|
+
`minRating`).
|
|
185
|
+
- Don't store review content long-term. The route's cache is short-lived; check Google's current
|
|
186
|
+
Places API policies before raising `NUXT_GOOGLE_REVIEWS_CACHE_MAX_AGE` a lot.
|
|
187
|
+
|
|
188
|
+
## Notes
|
|
189
|
+
|
|
190
|
+
- Out of scope: more than 5 reviews, multiple places per site, sorting other than Google's
|
|
191
|
+
relevance order, auto-advance, review structured data (Google doesn't give review rich results
|
|
192
|
+
for a business's own reviews).
|
|
193
|
+
- Storybook can't reach the server route; stories use mock `GoogleReviewsData`.
|
|
@@ -156,6 +156,10 @@ to mirror `InputTextCore` directly:
|
|
|
156
156
|
`var(--input-number-surface-hover, var(--theme-input-surface-hover))`) rather than kept as
|
|
157
157
|
private wrappers. `--_border-focus` stayed private since it's now genuinely reused (the
|
|
158
158
|
wrapper's own focus outline *and* the embedded button's focus outline both read it).
|
|
159
|
+
- Fixed 2026-10-04: the wrapper's hover and focus outlines only matched `input:hover` /
|
|
160
|
+
`input:focus-visible`, so hovering or tabbing to a step button showed no outer ring, unlike
|
|
161
|
+
`InputTextCore` (and so `InputTextAsNumberWithLabel`), which matches `button` too. Both
|
|
162
|
+
selectors are now `:has(input:…, button:…)`, mirroring `InputTextCore`.
|
|
159
163
|
|
|
160
164
|
**Separately found in the same investigation** (not an `InputNumber` bug, but what the visual
|
|
161
165
|
comparison was originally checking against): `InputTextAsNumberWithLabel.vue` had its own,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# InputOtp Component
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`InputOtp` is a one-time-code entry control: a row of single-digit boxes for verification codes
|
|
6
|
+
sent by email or SMS (sign-in, two-factor, email confirmation). It has no label, description or
|
|
7
|
+
error rendering of its own; that's composed by the `InputOtpField` variant below.
|
|
8
|
+
|
|
9
|
+
Most consumers should reach for **InputOtpField**.
|
|
10
|
+
|
|
11
|
+
Location: `app/components/05.forms/input-otp/`. Styling: `CONSUMER-STYLING.md` in that folder.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Behaviour
|
|
16
|
+
|
|
17
|
+
- Only digits are accepted. Typing a digit fills the box and moves focus to the next one.
|
|
18
|
+
- Typing into a box that already holds a digit replaces it (focusing a box selects its contents).
|
|
19
|
+
- Backspace in an empty box clears the previous box and moves focus there; Backspace in a filled
|
|
20
|
+
box only clears that box.
|
|
21
|
+
- Pasting spreads the digits across the boxes, strips non-digits and truncates to `length`. A
|
|
22
|
+
full-length paste always fills from the first box; a shorter one fills from the focused box.
|
|
23
|
+
Focus lands on the last box filled.
|
|
24
|
+
- Autofill works: the first box has `autocomplete="one-time-code"`, and several characters landing
|
|
25
|
+
in one box (iOS/Android SMS autofill) are spread the same way as a paste.
|
|
26
|
+
- Enter submits the parent form via `form.requestSubmit()` (with the native implicit submission
|
|
27
|
+
suppressed, so it never submits twice). Several single-character inputs don't otherwise get
|
|
28
|
+
implicit submission when the form has no submit button.
|
|
29
|
+
- Left/Right arrows move between boxes; Home/End jump to the first/last box.
|
|
30
|
+
- The first box is focused on mount only when `autofocus` is set. When `fieldHasError` turns on,
|
|
31
|
+
focus moves to the first box so the user can retype straight away.
|
|
32
|
+
- `complete` fires with the code whenever a change leaves every box filled.
|
|
33
|
+
- Each box has a visually hidden label built from `digitLabel` (translatable). In
|
|
34
|
+
`InputOtpField` the fieldset legend names the group; on its own, `groupLabel` adds
|
|
35
|
+
`role="group"` + `aria-label`.
|
|
36
|
+
- On error every box gets `aria-invalid="true"` and the `aria-describedby` ids.
|
|
37
|
+
- With `name` set, a hidden input carries the full code, so a native form submit sends one value.
|
|
38
|
+
The boxes themselves have no `name`.
|
|
39
|
+
- **Leading zeros are kept.** The model is a `string` and the boxes are `type="text"` with
|
|
40
|
+
`inputmode="numeric"`; nothing is ever converted to a number, so `"012345"` stays `"012345"`.
|
|
41
|
+
- `length` is clamped to 1–12. Non-digits and overflow in an incoming model value are ignored
|
|
42
|
+
for display (they show as empty boxes).
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Props reference
|
|
47
|
+
|
|
48
|
+
> Write camelCase props hyphenated in templates (`:field-has-error`, `:digit-label`).
|
|
49
|
+
|
|
50
|
+
| Prop (template form) | Type | Default | Notes |
|
|
51
|
+
|------|------|---------|-------|
|
|
52
|
+
| `:id` | `string` | (required) | Box ids are `{id}-0`, `{id}-1`, ... |
|
|
53
|
+
| `:name` | `string` | `""` | Name of the hidden input holding the full code. No hidden input when empty. |
|
|
54
|
+
| `:length` | `number` | `6` | Number of boxes, clamped 1–12. |
|
|
55
|
+
| `:autofocus` | `boolean` | `false` | Focus the first box on mount. Use when the field appears as the direct result of a user action (e.g. after submitting an email step). |
|
|
56
|
+
| `:required` | `boolean` | `false` | Native `required` on every box. |
|
|
57
|
+
| `:group-label` | `string` | `""` | When set, root gets `role="group"` and this `aria-label`. Leave empty inside `InputOtpField`. |
|
|
58
|
+
| `:digit-label` | `string` | `"Digit {index} of {length}"` | Per-box hidden label; `{index}` (1-based) and `{length}` are replaced. |
|
|
59
|
+
| `:field-has-error` | `boolean` | `false` | `data-invalid` on the root, `aria-invalid` on each box, focuses the first box when it turns on. |
|
|
60
|
+
| `:aria-describedby` | `string` | `""` | Forwarded to every box. |
|
|
61
|
+
| `:theme` | `FormUiTheme` | `"default"` | `data-theme` on the root. |
|
|
62
|
+
| `:input-variant` | `InputUiVariant` | `"normal"` | `normal` (bordered boxes) or `underlined`. `outlined` has no CSS, same as the other `05.forms` inputs. |
|
|
63
|
+
| `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the `.input-otp` root. |
|
|
64
|
+
|
|
65
|
+
### v-model
|
|
66
|
+
|
|
67
|
+
`v-model`: `string`, **required**. Digits only; unfilled trailing boxes are trimmed, so a
|
|
68
|
+
half-entered code is e.g. `"123"`. A gap in the middle (box 2 cleared while box 3 is filled)
|
|
69
|
+
is a space.
|
|
70
|
+
|
|
71
|
+
### Events
|
|
72
|
+
|
|
73
|
+
| Event | Payload | When |
|
|
74
|
+
|------|------|------|
|
|
75
|
+
| `complete` | `code: string` | A change leaves every box filled. Use it to auto-submit. |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Variants
|
|
80
|
+
|
|
81
|
+
### InputOtpField
|
|
82
|
+
|
|
83
|
+
`FormFieldset` (the label is the `<legend>`) + `InputDescription` + `InputOtp` + `InputError`,
|
|
84
|
+
with ids linked through `useAriaDescribedById`. Root class `.input-otp-field` (on the fieldset).
|
|
85
|
+
|
|
86
|
+
| Prop | Type | Default | Notes |
|
|
87
|
+
|------|------|---------|-------|
|
|
88
|
+
| `name` | `string` | (required) | Used for the ids and the hidden input's name. |
|
|
89
|
+
| `label` | `string` | (required) | Fieldset legend. |
|
|
90
|
+
| `errorMessage` | `string \| object` | `""` | Shown in `InputError` when `fieldHasError`. |
|
|
91
|
+
| `length`, `autofocus`, `required`, `digitLabel`, `fieldHasError`, `theme`, `inputVariant`, `styleClassPassthrough` | | | As on `InputOtp`; passthrough goes on the fieldset. |
|
|
92
|
+
|
|
93
|
+
Slots: `descriptionText`, `descriptionHtml` (linked via `aria-describedby`). Emits `complete`.
|
|
94
|
+
|
|
95
|
+
```vue
|
|
96
|
+
<form @submit.prevent="verify">
|
|
97
|
+
<InputOtpField
|
|
98
|
+
v-model="code"
|
|
99
|
+
name="code"
|
|
100
|
+
label="Verification code"
|
|
101
|
+
autofocus
|
|
102
|
+
:error-message="codeError"
|
|
103
|
+
:field-has-error="Boolean(codeError)"
|
|
104
|
+
@complete="verify"
|
|
105
|
+
>
|
|
106
|
+
<template #descriptionText>Enter the 6-digit code we sent to your email.</template>
|
|
107
|
+
</InputOtpField>
|
|
108
|
+
</form>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Migrating from an app-local `InputOtp`
|
|
114
|
+
|
|
115
|
+
This was ported from a consumer app's own `InputOtp.vue` (2026-10-04). Differences: the app version
|
|
116
|
+
took `id` + `label` in one component; use `InputOtpField` with `name` instead of `id`. It always
|
|
117
|
+
focused the first box on mount; pass `autofocus` to keep that. Its boxes were named `{id}-{index}`;
|
|
118
|
+
read the code from `v-model` or the hidden input (`name`) instead. Rename the app's file away (or
|
|
119
|
+
delete it) once on the layer release that ships this, or the app's own `InputOtp` shadows the
|
|
120
|
+
layer's.
|