@clicktap/ui 2.0.8 → 2.0.10
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/components/Badge/Badge.d.ts +17 -0
- package/components/Badge/Badge.js +1 -1
- package/components/Badge/Badge.types.d.ts +20 -1
- package/docs/migrations/2026/08/ui-2026-08-26-badge-classnames-badge-targets-the-pill.md +116 -0
- package/docs/migrations/index.json +10 -0
- package/package.json +1 -1
- package/utils/collection/filterSearchParams.d.ts +1 -6
- package/utils/collection/filterSearchParams.js +1 -1
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
import { BadgeProps } from './Badge.types';
|
|
2
|
+
/**
|
|
3
|
+
* ⚠️ Slot map — `classNames.badge` is the PILL (CTAP-1491).
|
|
4
|
+
*
|
|
5
|
+
* `@heroui/badge` rendered two elements, and its single badge element carried BOTH the absolute
|
|
6
|
+
* positioning and the whole visual treatment, so `classNames.badge` reached the thing that draws
|
|
7
|
+
* the badge. CTAP-1383 replaced it with this component, which splits that element in two — a
|
|
8
|
+
* positioning span and an animated pill — and the slot stayed on the positioning half. The pill
|
|
9
|
+
* then had no slot at all, so a consumer restyling "the badge" painted and sized a transparent
|
|
10
|
+
* wrapper while the pill kept its `variant` styling underneath: a coloured square behind the pill.
|
|
11
|
+
*
|
|
12
|
+
* Applying `badge` to both halves is not the fix — that is what paints the wrapper. So each element
|
|
13
|
+
* has its own slot:
|
|
14
|
+
*
|
|
15
|
+
* base the relative anchor (unchanged)
|
|
16
|
+
* wrapper the absolutely-positioned span — placement and offset only
|
|
17
|
+
* badge the visible pill — colour, size, radius, typography
|
|
18
|
+
*/
|
|
2
19
|
export declare function Badge({ children, isInvisible, disableAnimation, placement, shape, className, classNames, showOutline, content, isDot, isOneChar, size, variant, ...props }: BadgeProps): import("react/jsx-runtime").JSX.Element;
|
|
3
20
|
export default Badge;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import{jsxs as V,jsx as
|
|
1
|
+
"use client";import{jsxs as V,jsx as a}from"react/jsx-runtime";import{useId as v}from"react";import{AnimatePresence as z,m as C}from"framer-motion";import{cn as l}from"../../utils/cn.js";const S={hidden:{opacity:0,scale:0},show:{opacity:1,scale:1}};function P({children:h,isInvisible:f,disableAnimation:d,placement:t="top-right",shape:e="rectangle",className:g,classNames:i,showOutline:c=!1,content:o,isDot:s,isOneChar:x,size:n="md",variant:r="solid",...b}){const m=v(),p=d?{duration:0}:{type:"spring",bounce:.3},w=String(o)?.length===1||x,u={sm:"text-xs",md:"text-sm",lg:"text-sm"},y=w?{sm:"w-4 h-4",md:"w-5 h-5",lg:"w-6 h-6"}[n]:{sm:"min-w-4 h-4",md:"min-w-5 h-5",lg:"min-w-6 h-6"}[n];return V("div",{...b,className:l("relative","inline-flex shrink-0",g,i?.base),children:[h,a("span",{className:l("p-0","border-0","absolute z-10","flex",[t==="top-right"&&e==="rectangle"&&"top-0.5 right-px",t==="top-right"&&e==="circle"&&"top-1 right-1",t==="bottom-right"&&e==="rectangle"&&"bottom-0.5 right-px",t==="bottom-right"&&e==="circle"&&"bottom-1 right-1",t==="top-left"&&e==="rectangle"&&"top-0.5 left-px",t==="top-left"&&e==="circle"&&"top-1 left-1",t==="bottom-left"&&e==="rectangle"&&"bottom-0.5 left-px",t==="bottom-left"&&e==="circle"&&"bottom-1 left-1"],[t==="top-right"&&"translate-x-1/2 -translate-y-1/2",t==="bottom-right"&&"translate-x-1/2 translate-y-1/2",t==="bottom-left"&&"-translate-x-1/2 translate-y-1/2",t==="top-left"&&"-translate-x-1/2 -translate-y-1/2"],i?.wrapper),children:a(z,{children:!f&&a(C.span,{layout:!0,variants:S,initial:d?"show":"hidden",transition:p,animate:"show",exit:"hidden",className:l("flex flex-wrap","place-content-center","z-10","box-border","rounded-full","py-0 px-1","font-normal text-fg","select-none","whitespace-normal",r==="faded"?"text-slate-800":"text-white",!o||s?"w-3.5 h-3.5":y,typeof o=="string"&&o.length>1||s?"text-xs":u[n],[r==="solid"&&"bg-surface-inverse-hover",r==="shadow"&&"bg-surface-inverse-hover",r==="flat"&&"bg-surface-inverse-hover/55",r==="faded"&&"bg-white"],[r==="faded"&&"border-2 border-solid border-surface-inverse-hover",c&&r!=="faded"&&"border-2 border-solid border-white"],i?.badge),children:!s&&o},`badge-${m}`)})})]})}export{P as Badge,P as default};
|
|
@@ -4,7 +4,13 @@ import { HTMLAttributes, ReactNode } from 'react';
|
|
|
4
4
|
* The heroui component only contributed two wrapper elements while every
|
|
5
5
|
* visual class already lived here — importing it cost the whole @heroui
|
|
6
6
|
* system (~25 KiB gz) in the initial bundle of every page via the header's
|
|
7
|
-
* cart badge.
|
|
7
|
+
* cart badge.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ Prop names and defaults match the heroui version, but `classNames` does NOT map one-to-one and
|
|
10
|
+
* an earlier revision of this comment claimed it did. heroui rendered a single badge element that
|
|
11
|
+
* carried both positioning and appearance; this component splits it into a positioning wrapper and
|
|
12
|
+
* a pill, so there are three slots rather than two. `classNames.badge` still means the visible pill
|
|
13
|
+
* — see {@link BadgeProps.classNames} (CTAP-1491).
|
|
8
14
|
*/
|
|
9
15
|
export type BadgeProps = Omit<HTMLAttributes<HTMLDivElement>, 'content'> & {
|
|
10
16
|
children?: ReactNode;
|
|
@@ -15,8 +21,21 @@ export type BadgeProps = Omit<HTMLAttributes<HTMLDivElement>, 'content'> & {
|
|
|
15
21
|
disableAnimation?: boolean;
|
|
16
22
|
placement?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
|
17
23
|
shape?: 'rectangle' | 'circle';
|
|
24
|
+
/**
|
|
25
|
+
* Per-element class slots.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ `badge` is the visible PILL, not the positioning wrapper. Between CTAP-1383 and CTAP-1491 it
|
|
28
|
+
* landed on the wrapper, which has no background or radius of its own — so classes meant for the
|
|
29
|
+
* pill painted and sized a transparent box around it instead. If you worked around that with
|
|
30
|
+
* child variants (`[&>span]:bg-…`), drop the `[&>span]:` prefix: those classes now belong on
|
|
31
|
+
* `badge` directly, and left as-is they would target a child of the pill that does not exist.
|
|
32
|
+
*/
|
|
18
33
|
classNames?: {
|
|
34
|
+
/** The relative anchor wrapping `children`. */
|
|
19
35
|
base?: string;
|
|
36
|
+
/** The absolutely-positioned span — placement and offset only; has no visual styling. */
|
|
37
|
+
wrapper?: string;
|
|
38
|
+
/** The visible pill — colour, size, radius, typography. */
|
|
20
39
|
badge?: string;
|
|
21
40
|
};
|
|
22
41
|
showOutline?: boolean;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# BUGFIX: Badge `classNames.badge` targets the pill again, and the wrapper gets its own slot
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-26
|
|
4
|
+
**Package:** `@clicktap/ui`
|
|
5
|
+
**Affects:** `src/components/Badge/Badge.tsx`, `Badge.types.ts`
|
|
6
|
+
**Classification:** BUGFIX
|
|
7
|
+
**Breaking:** ⚠️ Yes for anyone who worked around the CTAP-1383 behaviour — see Consumer Impact.
|
|
8
|
+
**Ticket:** CTAP-1491
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
`@heroui/badge` rendered **two** elements:
|
|
13
|
+
|
|
14
|
+
```jsx
|
|
15
|
+
<div className={slots.base(classNames.base)}>
|
|
16
|
+
{children}
|
|
17
|
+
<Component {...getBadgeProps()}>{content}</Component> // slots.badge + classNames.badge
|
|
18
|
+
</div>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That second element *was* the badge: it carried the absolute positioning **and** the whole visual
|
|
22
|
+
treatment. So `classNames.badge` reached the thing that draws the badge.
|
|
23
|
+
|
|
24
|
+
CTAP-1383 replaced heroui with an in-house Badge — worth doing, since importing heroui pulled ~25 KiB
|
|
25
|
+
gz into every page's initial bundle via the header's cart badge. But it splits that one element into
|
|
26
|
+
**three**:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
<div> base relative anchor
|
|
30
|
+
<span> — absolute positioning: placement, translate
|
|
31
|
+
<m.span> — the visible pill: rounded-full, bg, size, text, variant
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
…and `classNames.badge` stayed on the **middle** one. The pill had no slot at all, so its appearance
|
|
35
|
+
was reachable only through `variant` and `size`.
|
|
36
|
+
|
|
37
|
+
⚠️ A consumer restyling "the badge" therefore painted and sized a wrapper that has no background and
|
|
38
|
+
no `rounded-full` of its own, while the pill kept its `variant` styling underneath — rendering a
|
|
39
|
+
coloured square behind the pill.
|
|
40
|
+
|
|
41
|
+
⚠️ **Nothing could catch this.** The prop still existed with the same name and type, so consumer code
|
|
42
|
+
compiled, typechecked and linted clean; only the runtime element differed. `Badge.types.ts` also
|
|
43
|
+
asserted "Same prop names and defaults as before", which was true of the signature and false of the
|
|
44
|
+
behaviour — that sentence is now corrected.
|
|
45
|
+
|
|
46
|
+
## What changed
|
|
47
|
+
|
|
48
|
+
Three slots, one per element:
|
|
49
|
+
|
|
50
|
+
| slot | element | what belongs there |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `base` | the relative anchor | unchanged |
|
|
53
|
+
| `wrapper` | **new** — the absolutely-positioned span | placement, offset |
|
|
54
|
+
| `badge` | the visible pill | colour, size, radius, typography |
|
|
55
|
+
|
|
56
|
+
⚠️ Applying `badge` to both halves was considered and rejected — painting the wrapper is the bug.
|
|
57
|
+
|
|
58
|
+
`classNames.badge` is merged **last** on the pill, so `cn` (tailwind-merge) reconciles a consumer's
|
|
59
|
+
size or colour against the computed `sizeVariant`/`variant` classes rather than leaving the outcome
|
|
60
|
+
to specificity or stylesheet order.
|
|
61
|
+
|
|
62
|
+
## Consumer Impact
|
|
63
|
+
|
|
64
|
+
**If you never passed `classNames` to a Badge, nothing changes.** No component in this repo does,
|
|
65
|
+
which is why the regression went unnoticed upstream in the first place.
|
|
66
|
+
|
|
67
|
+
**If you have pre-CTAP-1383 (heroui-era) code**, your `classNames.badge` starts working again with no
|
|
68
|
+
edit — that is the point of restoring the meaning.
|
|
69
|
+
|
|
70
|
+
⚠️ **If you worked around CTAP-1383 with child variants, you must undo it.** The known shape is:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// BEFORE — targeting the pill through the wrapper
|
|
74
|
+
badge: cn(
|
|
75
|
+
'top-px',
|
|
76
|
+
'[&>span]:bg-brand-red [&>span]:text-white [&>span]:border-0',
|
|
77
|
+
'[&>span]:min-w-5 [&>span]:h-5',
|
|
78
|
+
),
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
// AFTER — the pill has its own slot; positioning moves to `wrapper`
|
|
83
|
+
wrapper: 'top-px',
|
|
84
|
+
badge: cn(
|
|
85
|
+
'bg-brand-red text-white border-0',
|
|
86
|
+
'min-w-5 h-5',
|
|
87
|
+
),
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Left unchanged, `[&>span]:` now targets a `span` child *of the pill*, which does not exist — so every
|
|
91
|
+
one of those declarations silently stops applying and the badge reverts to its `variant` styling.
|
|
92
|
+
⚠️ Same failure shape as the original bug, so grep for `[&>span]:` near a Badge before upgrading.
|
|
93
|
+
|
|
94
|
+
That workaround was always brittle for a second reason worth retiring: it depended on the pill
|
|
95
|
+
remaining a direct `span` child of the wrapper, so any future wrapper element inside that span would
|
|
96
|
+
have detached it silently.
|
|
97
|
+
|
|
98
|
+
## Verification
|
|
99
|
+
|
|
100
|
+
`src/components/Badge/Badge.spec.tsx` — 6 tests, the first exerciser `classNames` has had.
|
|
101
|
+
|
|
102
|
+
**Mutation-tested.** Reverting to the CTAP-1383 wiring fails **5 of 6**. The sixth (`base` on the
|
|
103
|
+
anchor) passes either way, correctly — `base` was never broken.
|
|
104
|
+
|
|
105
|
+
Pinned deliberately: `badge` lands on the pill; `badge` does **not** also land on the wrapper (a
|
|
106
|
+
positive-only assertion would pass if it were applied to both — the other wrong answer, and the one
|
|
107
|
+
that reproduces the coloured square); `wrapper` lands on the wrapper and not the pill; `base` on the
|
|
108
|
+
anchor only; the three slots resolve to three **different** elements (one element carrying all three
|
|
109
|
+
would satisfy each assertion taken alone); and a consumer's `h-6` beats the computed `h-5` with no
|
|
110
|
+
`h-5` left behind, which is what the merge-last ordering buys.
|
|
111
|
+
|
|
112
|
+
A `ClassNameSlots` story tints each slot so the mapping is visible in Storybook.
|
|
113
|
+
|
|
114
|
+
⚠️ **Not verified visually against a real brand theme.** The tests assert which element each class
|
|
115
|
+
lands on, in jsdom. That the resulting badge *looks* right on a consumer's storefront is not
|
|
116
|
+
something this suite can show.
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"path": "2026/08/ui-2026-08-26-badge-classnames-badge-targets-the-pill.md",
|
|
4
|
+
"module": "libs/frontend-modules/ui",
|
|
5
|
+
"package": "@clicktap/ui",
|
|
6
|
+
"date": "2026-08-26",
|
|
7
|
+
"title": "BUGFIX: Badge `classNames.badge` targets the pill again, and the wrapper gets its own slot",
|
|
8
|
+
"classification": "BUGFIX",
|
|
9
|
+
"breaking": "⚠️ Yes for anyone who worked around the CTAP-1383 behaviour — see Consumer Impact.",
|
|
10
|
+
"affects": "`src/components/Badge/Badge.tsx`, `Badge.types.ts`"
|
|
11
|
+
},
|
|
2
12
|
{
|
|
3
13
|
"path": "2026/08/ui-2026-08-24-production-jsx-runtime-guard.md",
|
|
4
14
|
"module": "libs/frontend-modules/ui",
|
package/package.json
CHANGED
|
@@ -5,9 +5,4 @@ import { CollectionFilterAppliedInterface } from '../../types/collection';
|
|
|
5
5
|
* Boolean filters: ?is_new=true
|
|
6
6
|
*/
|
|
7
7
|
export declare function filtersToSearchParams(appliedFilters: CollectionFilterAppliedInterface[]): URLSearchParams;
|
|
8
|
-
|
|
9
|
-
* Deserialize URL search params into a minimal applied filters structure.
|
|
10
|
-
* Returns partial filter objects with code + options/value — enough for
|
|
11
|
-
* buildFilterGroups to construct the GraphQL query.
|
|
12
|
-
*/
|
|
13
|
-
export declare function searchParamsToFilters(params: Record<string, string | string[] | undefined>): CollectionFilterAppliedInterface[];
|
|
8
|
+
export declare function searchParamsToFilters(params: Record<string, string | string[] | undefined>, validAttributeCodes?: Iterable<string>): CollectionFilterAppliedInterface[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
function
|
|
1
|
+
function c(r){const i=new URLSearchParams;for(const e of r){const n="__typename"in e?e.__typename:void 0;if(n==="CollectionFilterAppliedOption"&&"options"in e){const l=e.options.filter(o=>o.isApplied).map(o=>o.code);l.length>0&&i.set(e.code,l.join(","))}else if(n==="CollectionFilterAppliedBoolean"&&"appliedValue"in e){const t=e;i.set(e.code,t.appliedValue?"true":"false")}}return i}const p=r=>r.replace(/-/g,"_");function d(r,i){const e=i?new Set([...i].map(p)):null,n=[];for(const[t,l]of Object.entries(r)){if(!l||e&&!e.has(p(t)))continue;const o=Array.isArray(l)?l[0]:l;if(o)if(o==="true"||o==="false")n.push({__typename:"CollectionFilterAppliedBoolean",code:t,label:t,renderer:"boolean",type:"boolean",includeInFilters:!0,appliedValue:o==="true"});else{const a=o.split(",").filter(Boolean);a.length>0&&n.push({__typename:"CollectionFilterAppliedOption",code:t,label:t,renderer:"option",type:"option",includeInFilters:!0,isSearchable:!1,options:a.map(s=>({id:"",code:s,label:"",isApplied:!0}))})}}return n}export{c as filtersToSearchParams,d as searchParamsToFilters};
|