@clicktap/ui 2.0.9 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Button/Button.d.ts +1 -1
- package/components/Button/Button.js +1 -1
- package/components/Button/Button.types.d.ts +1 -1
- package/components/LinkAsButton/LinkAsButton.js +1 -1
- package/docs/migrations/2026/09/ui-2026-09-01-button-link-variant.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,5 +1,5 @@
|
|
|
1
1
|
import { ButtonVariant } from './Button.types';
|
|
2
|
-
export declare function resolveVariant(variant: ButtonVariant): 'solid' | 'outline' | 'ghost';
|
|
2
|
+
export declare function resolveVariant(variant: ButtonVariant): 'solid' | 'outline' | 'ghost' | 'link';
|
|
3
3
|
export declare const Button: import('react').ForwardRefExoticComponent<import('react-aria-components').ButtonProps & {
|
|
4
4
|
isLoading?: boolean;
|
|
5
5
|
variant?: ButtonVariant;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import{jsx as f}from"react/jsx-runtime";import{forwardRef as
|
|
1
|
+
"use client";import{jsx as f}from"react/jsx-runtime";import{forwardRef as p}from"react";import{Button as c}from"react-aria-components";import{cn as g}from"../../utils/cn.js";function h(t){switch(t){case"primary":return"solid";case"secondary":return"outline";case"tertiary":return"ghost";default:return t}}const w=p(function({className:s,isDisabled:n=!1,isLoading:e=!1,size:o="md",variant:a="solid",...d},b){const r=h(a),u=["flex items-center justify-center","rounded-md border-solid border","font-semibold text-sm","py-2 px-8","transition-all duration-200 ease-in-out","focus:outline-2 focus:outline focus:outline-border-subtle pressed:scale-95",o==="sm"&&"h-8",o==="md"&&"h-10",o==="lg"&&"h-12",r==="ghost"&&["bg-transparent hover:bg-transparent focus:bg-transparent disabled:bg-transparent","border-transparent hover:border-transparent focus:border-transparent disabled:border-transparent","text-fg disabled:text-fg-subtle",e&&"disabled:text-fg"],r==="link"&&["bg-transparent hover:bg-transparent focus:bg-transparent disabled:bg-transparent","border-transparent hover:border-transparent focus:border-transparent disabled:border-transparent","inline-flex h-auto p-0 rounded-none","underline underline-offset-2","text-fg disabled:text-fg-muted hover:opacity-70",e&&"disabled:text-fg"],r==="outline"&&["bg-transparent hover:bg-transparent focus:bg-transparent disabled:bg-transparent","border-border hover:border-border-emphasis focus:border-border-emphasis disabled:border-border-subtle","text-fg disabled:text-fg-muted",e&&["disabled:border-border","disabled:text-fg"]],r==="solid"&&["bg-surface-inverse-hover hover:bg-surface-inverse focus:bg-surface-inverse disabled:bg-surface-inverse","border-surface-inverse-hover hover:border-fg focus:border-fg disabled:border-border-subtle","text-white disabled:text-fg-subtle",e&&["disabled:border-fg","disabled:text-white"]]];return f(c,{ref:b,type:"button",isDisabled:n,className:i=>{const l=typeof s=="function"?s(i):s;return g(u,l)},...d})});export{w as Button,w as default,h as resolveVariant};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ButtonProps as AriaButtonProps } from 'react-aria-components';
|
|
2
|
-
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'primary' | 'secondary' | 'tertiary';
|
|
2
|
+
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link' | 'primary' | 'secondary' | 'tertiary';
|
|
3
3
|
export type ButtonProps = AriaButtonProps & {
|
|
4
4
|
isLoading?: boolean;
|
|
5
5
|
variant?: ButtonVariant;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use client";import{jsx as u}from"react/jsx-runtime";import{forwardRef as p}from"react";import{cn as
|
|
1
|
+
"use client";import{jsx as u}from"react/jsx-runtime";import{forwardRef as p}from"react";import{cn as g}from"../../utils/cn.js";import{Link as c}from"../Link/Link.js";import"../Link/LinkContext.js";import{resolveVariant as m}from"../Button/Button.js";import"react-aria-components";import"../Link/utils.js";const h=p(({className:t,isDisabled:o,isLoading:e=!1,size:s="md",variant:d="solid",children:n,...a},b)=>{const r=m(d),i=["flex items-center justify-center","rounded-md border-solid border","font-semibold text-sm","py-2 px-8","transition-all duration-200 ease-in-out","focus:outline-2 focus:outline focus:outline-border-subtle pressed:scale-95",s==="sm"&&"h-8",s==="md"&&"h-10",s==="lg"&&"h-12",r==="ghost"&&["bg-transparent hover:bg-transparent focus:bg-transparent disabled:bg-transparent","border-transparent hover:border-transparent focus:border-transparent disabled:border-transparent","text-fg disabled:text-fg-subtle",e&&"disabled:text-fg"],r==="link"&&["bg-transparent hover:bg-transparent focus:bg-transparent disabled:bg-transparent","border-transparent hover:border-transparent focus:border-transparent disabled:border-transparent","inline-flex h-auto p-0 rounded-none","underline underline-offset-2","text-fg disabled:text-fg-muted hover:opacity-70",e&&"disabled:text-fg"],r==="outline"&&["bg-transparent hover:bg-transparent focus:bg-transparent disabled:bg-transparent","border-border hover:border-border-emphasis focus:border-border-emphasis disabled:border-border-subtle","text-fg disabled:text-fg-muted",e&&["disabled:border-border","disabled:text-fg"]],r==="solid"&&["bg-surface-inverse-hover hover:bg-surface-inverse focus:bg-surface-inverse disabled:bg-surface-inverse","border-surface-inverse-hover hover:border-fg focus:border-fg disabled:border-border-subtle","text-white disabled:text-fg-subtle hover:text-white",e&&["disabled:border-fg","disabled:text-white"]]];return u(c,{ref:b,isDisabled:o,className:l=>{const f=typeof t=="function"?t(l):t;return g(i,f)},...a,children:n})});h.displayName="LinkAsButton";export{h as LinkAsButton,h as default};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# IMPROVEMENT: `Button` and `LinkAsButton` gain a `link` variant
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-09-01
|
|
4
|
+
**Package:** `@clicktap/ui`
|
|
5
|
+
**Affects:** `src/components/Button/Button.tsx`, `Button.types.ts`, `src/components/LinkAsButton/LinkAsButton.tsx`
|
|
6
|
+
**Classification:** IMPROVEMENT
|
|
7
|
+
**Breaking:** No — additive. `ButtonVariant` widens, which only affects code that
|
|
8
|
+
enumerates it exhaustively; see Consumer Impact.
|
|
9
|
+
**Ticket:** CTAP-1569
|
|
10
|
+
|
|
11
|
+
## Problem
|
|
12
|
+
|
|
13
|
+
Some actions are secondary enough that a button is the wrong weight — "set as
|
|
14
|
+
default" sitting next to a card's edit/delete icons, for instance. Until now the
|
|
15
|
+
only way to render one was to take an `outline` button and argue it back into a
|
|
16
|
+
link at the call site:
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
<Button
|
|
20
|
+
variant="outline"
|
|
21
|
+
className={cn(
|
|
22
|
+
'!text-xs rounded-lg text-xs uppercase underline text-slate-800 p-0 data-[hovered]:text-slate-800',
|
|
23
|
+
'border-0 outline-0 hover:border-0 focus:border-0 hover:outline-0 focus:outline-0',
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Nine utilities, six of which exist only to cancel the variant that was just
|
|
29
|
+
asked for. ⚠️ That shape does not survive contact with a second call site: the
|
|
30
|
+
account area had this on My Addresses and a plain `variant="outline"` button on
|
|
31
|
+
both saved-card screens, so one action rendered three different ways. Nothing
|
|
32
|
+
flags the drift — every copy compiles and lints clean.
|
|
33
|
+
|
|
34
|
+
## What changed
|
|
35
|
+
|
|
36
|
+
`ButtonVariant` gains `'link'`, and both `Button` and `LinkAsButton` style it:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
<Button variant="link" onPress={setAsDefault}>Set as default</Button>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It keeps the element (`<button>` for `Button`, `<a>` for `LinkAsButton`) and
|
|
43
|
+
sheds the button box:
|
|
44
|
+
|
|
45
|
+
| | `outline` | `link` |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| padding | `py-2 px-8` | `p-0` |
|
|
48
|
+
| height | `h-8` / `h-10` / `h-12` by `size` | `h-auto` — **`size` has no effect** |
|
|
49
|
+
| border | `border-border` | `border-transparent` |
|
|
50
|
+
| radius | `rounded-md` | `rounded-none` |
|
|
51
|
+
| decoration | none | `underline underline-offset-2` |
|
|
52
|
+
| hover | border darkens | `opacity-70` |
|
|
53
|
+
|
|
54
|
+
Two consequences worth knowing:
|
|
55
|
+
|
|
56
|
+
- **`size` is inert on `link`.** Its height comes from its own line box, so all
|
|
57
|
+
three sizes render identically. Change the inherited font size instead.
|
|
58
|
+
- **Hover is `opacity-70`, not a color change.** That is deliberate: a call site
|
|
59
|
+
that overrides the text color (`text-slate-800`) keeps a working hover without
|
|
60
|
+
also having to override a `hover:text-*` it did not ask for. This is what the
|
|
61
|
+
old workaround's `data-[hovered]:text-slate-800` was for.
|
|
62
|
+
|
|
63
|
+
`link` has no semantic alias. `primary`/`secondary`/`tertiary` still map to
|
|
64
|
+
`solid`/`outline`/`ghost`; there is no fourth alias, because a link is a
|
|
65
|
+
presentational choice rather than a rung on the emphasis ladder.
|
|
66
|
+
|
|
67
|
+
## Consumer Impact
|
|
68
|
+
|
|
69
|
+
**No action needed to upgrade.** Existing variants are untouched — the spec pins
|
|
70
|
+
that `outline` still renders `px-8 h-8 border-border` and no underline.
|
|
71
|
+
|
|
72
|
+
**If you have a hand-rolled link-button**, replace it. The old workaround still
|
|
73
|
+
works, but it fights the base layer through `cn`/tailwind-merge and will keep
|
|
74
|
+
drifting between copies:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// BEFORE
|
|
78
|
+
<Button variant="outline" className={cn(
|
|
79
|
+
'!text-xs rounded-lg text-xs uppercase underline text-slate-800 p-0 data-[hovered]:text-slate-800',
|
|
80
|
+
'border-0 outline-0 hover:border-0 focus:border-0 hover:outline-0 focus:outline-0',
|
|
81
|
+
)}>
|
|
82
|
+
|
|
83
|
+
// AFTER
|
|
84
|
+
<Button variant="link" className="text-xs uppercase text-slate-800">
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
⚠️ Drop `!text-xs` when you migrate. The `!important` was there to beat the base
|
|
88
|
+
`text-sm` through specificity; `cn` resolves that by merge order now, and a
|
|
89
|
+
lingering `!important` will beat your own later overrides too.
|
|
90
|
+
|
|
91
|
+
⚠️ **If you enumerate `ButtonVariant` exhaustively** — a `switch` with no
|
|
92
|
+
`default`, a `Record<ButtonVariant, …>`, a radio-control options array — the
|
|
93
|
+
widened union is a compile error (`Record`) or a silent gap (`switch`). Both are
|
|
94
|
+
one-line fixes; `resolveVariant`'s return type widened to
|
|
95
|
+
`'solid' | 'outline' | 'ghost' | 'link'` for the same reason, so a consumer
|
|
96
|
+
narrowing on its result needs the new arm.
|
|
97
|
+
|
|
98
|
+
## Verification
|
|
99
|
+
|
|
100
|
+
`src/components/Button/Button.spec.tsx` — 7 tests.
|
|
101
|
+
|
|
102
|
+
**Mutation-tested.** Deleting the `link` blocks from both components fails 4 of
|
|
103
|
+
7; the 3 that survive are correct to survive (it renders a `<button>`, the other
|
|
104
|
+
variants stay boxed, a call-site class still wins — none of those depend on the
|
|
105
|
+
new variant existing).
|
|
106
|
+
|
|
107
|
+
The assertions are deliberately **cancellation** assertions: they check `px-8`,
|
|
108
|
+
`py-2`, `rounded-md` and `border-border` are ABSENT from the final className, not
|
|
109
|
+
merely that `underline` and `p-0` are present. ⚠️ A positive-only test passes
|
|
110
|
+
against a variant that appends link classes while leaving the button box behind
|
|
111
|
+
— which renders a padded, bordered, underlined box. That is the wrong answer this
|
|
112
|
+
suite exists to catch.
|
|
113
|
+
|
|
114
|
+
⚠️ **Not visually verified against a brand theme.** The tests assert which
|
|
115
|
+
classes survive `cn` in jsdom. Whether the result reads as a link on a given
|
|
116
|
+
storefront's type scale is not something they can show.
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"path": "2026/09/ui-2026-09-01-button-link-variant.md",
|
|
4
|
+
"module": "libs/frontend-modules/ui",
|
|
5
|
+
"package": "@clicktap/ui",
|
|
6
|
+
"date": "2026-09-01",
|
|
7
|
+
"title": "IMPROVEMENT: `Button` and `LinkAsButton` gain a `link` variant",
|
|
8
|
+
"classification": "IMPROVEMENT",
|
|
9
|
+
"breaking": "No — additive. `ButtonVariant` widens, which only affects code that",
|
|
10
|
+
"affects": "`src/components/Button/Button.tsx`, `Button.types.ts`, `src/components/LinkAsButton/LinkAsButton.tsx`"
|
|
11
|
+
},
|
|
2
12
|
{
|
|
3
13
|
"path": "2026/08/ui-2026-08-26-badge-classnames-badge-targets-the-pill.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};
|