@stasho/ds 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stasho/ds",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "stasho design system",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,7 @@
45
45
  "./selectable-card": "./src/components/selectable-card/selectable-card.tsx",
46
46
  "./ui/skeleton": "./src/components/ui/skeleton.tsx",
47
47
  "./multi-select": "./src/components/multi-select/multi-select.tsx",
48
+ "./number-input": "./src/components/number-input/number-input.tsx",
48
49
  "./pagination": "./src/components/pagination/pagination.tsx",
49
50
  "./copyable-text": "./src/components/copyable-text/copyable-text.tsx",
50
51
  "./dialog": "./src/components/dialog/dialog.tsx",
@@ -28,6 +28,18 @@ const VARIANT_BG_CLASS: Record<AlertVariant, string> = {
28
28
  success: "alert-bg-success",
29
29
  };
30
30
 
31
+ // Mirrors the root's `duration-200` exit transition; the fallback timer below
32
+ // resolves a dismissal whose `transitionend` never arrives.
33
+ const EXIT_DURATION_MS = 200;
34
+ const EXIT_FALLBACK_MS = EXIT_DURATION_MS + 50;
35
+
36
+ function prefersReducedMotion(): boolean {
37
+ return (
38
+ typeof window !== "undefined" &&
39
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
40
+ );
41
+ }
42
+
31
43
  const alertVariants = cva(
32
44
  [
33
45
  "relative overflow-hidden rounded-sm border",
@@ -99,6 +111,8 @@ const Alert = forwardRef<HTMLDivElement, AlertProps>(
99
111
  const [isDismissing, setIsDismissing] = useState(false);
100
112
  const [progressActive, setProgressActive] = useState(false);
101
113
  const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
114
+ const fallbackRef = useRef<ReturnType<typeof setTimeout>>(null);
115
+ const dismissedRef = useRef(false);
102
116
  const resolvedVariant: AlertVariant = variant ?? "warning";
103
117
 
104
118
  const label = VARIANT_LABELS[resolvedVariant];
@@ -110,24 +124,38 @@ const Alert = forwardRef<HTMLDivElement, AlertProps>(
110
124
  setProgressActive(true);
111
125
  });
112
126
 
113
- timerRef.current = setTimeout(() => {
114
- setIsDismissing(true);
115
- }, dismissAfter);
127
+ timerRef.current = setTimeout(startDismiss, dismissAfter);
116
128
 
117
129
  return () => {
118
130
  if (timerRef.current) clearTimeout(timerRef.current);
119
131
  };
120
132
  }, [dismissAfter, onDismiss]);
121
133
 
122
- function handleDismiss() {
134
+ useEffect(() => {
135
+ return () => {
136
+ if (fallbackRef.current) clearTimeout(fallbackRef.current);
137
+ };
138
+ }, []);
139
+
140
+ function finishDismiss() {
141
+ if (dismissedRef.current) return;
142
+ dismissedRef.current = true;
143
+ if (fallbackRef.current) clearTimeout(fallbackRef.current);
144
+ onDismiss?.();
145
+ }
146
+
147
+ function startDismiss() {
123
148
  if (timerRef.current) clearTimeout(timerRef.current);
124
149
  setIsDismissing(true);
150
+ if (prefersReducedMotion()) {
151
+ finishDismiss();
152
+ return;
153
+ }
154
+ fallbackRef.current = setTimeout(finishDismiss, EXIT_FALLBACK_MS);
125
155
  }
126
156
 
127
157
  function handleTransitionEnd() {
128
- if (isDismissing && onDismiss) {
129
- onDismiss();
130
- }
158
+ if (isDismissing) finishDismiss();
131
159
  }
132
160
 
133
161
  return (
@@ -168,7 +196,7 @@ const Alert = forwardRef<HTMLDivElement, AlertProps>(
168
196
  {onDismiss ? (
169
197
  <button
170
198
  type="button"
171
- onClick={handleDismiss}
199
+ onClick={startDismiss}
172
200
  className={cn(
173
201
  "absolute top-2 right-2 cursor-pointer",
174
202
  labelVariants({ variant }),
@@ -89,7 +89,7 @@ const buttonVariants = cva(
89
89
  // dark (overrides): white chrome — outline is neutral, NOT accent (supersedes the outline half of Decision #82)
90
90
  "dark:text-white dark:border-white/40",
91
91
  "dark:hover:border-white",
92
- "dark:disabled:text-white/30 dark:disabled:border-white/10 dark:disabled:bg-transparent",
92
+ "dark:disabled:text-white/30 dark:disabled:border-white/10 dark:disabled:bg-neutral-900",
93
93
  ].join(" "),
94
94
  ghost: [
95
95
  // light (base): foreground text, surface hover
@@ -4,20 +4,22 @@ import { Command } from "cmdk";
4
4
  import { cva, type VariantProps } from "class-variance-authority";
5
5
  import { CaretDown, Check } from "@phosphor-icons/react";
6
6
  import { cn } from "@ac/lib/cn";
7
+ import {
8
+ fieldChassis,
9
+ fieldDisabled,
10
+ fieldError,
11
+ fieldFocus,
12
+ fieldTriggerHover,
13
+ } from "@ac/lib/field-chassis";
7
14
 
8
15
  const triggerVariants = cva(
9
16
  [
10
17
  "inline-flex items-center justify-between",
11
18
  "w-full font-sans text-foreground",
12
- "bg-background dark:bg-surface",
13
- "border border-edge rounded-sm",
14
- "hover:border-edge-hover",
15
- "focus-visible:outline-none",
16
- "focus-visible:border-accent-700 dark:focus-visible:border-accent",
17
- "disabled:bg-muted dark:disabled:bg-background",
18
- "disabled:border-edge/50",
19
- "disabled:text-foreground/30",
20
- "disabled:cursor-not-allowed",
19
+ fieldChassis,
20
+ fieldTriggerHover,
21
+ fieldFocus,
22
+ fieldDisabled,
21
23
  "transition-colors",
22
24
  ].join(" "),
23
25
  {
@@ -87,8 +89,7 @@ const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(
87
89
  aria-invalid={error || undefined}
88
90
  className={cn(
89
91
  triggerVariants({ size }),
90
- error &&
91
- "border-error hover:border-error focus-visible:border-error dark:focus-visible:border-error",
92
+ error && fieldError,
92
93
  !selectedLabel &&
93
94
  (disabled ? "text-muted-foreground/50" : "text-muted-foreground"),
94
95
  className,
@@ -1,20 +1,21 @@
1
1
  import { forwardRef, type InputHTMLAttributes } from "react";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { cn } from "@ac/lib/cn";
4
+ import {
5
+ fieldChassis,
6
+ fieldDisabled,
7
+ fieldError,
8
+ fieldFocus,
9
+ } from "@ac/lib/field-chassis";
4
10
 
5
11
  const inputVariants = cva(
6
12
  [
7
13
  "w-full font-sans text-foreground",
8
- "bg-background dark:bg-surface",
9
- "border border-edge rounded-sm",
14
+ fieldChassis,
10
15
  "placeholder:text-muted-foreground",
11
- "focus-visible:outline-none",
12
- "focus-visible:border-accent-700 dark:focus-visible:border-accent",
13
- "disabled:bg-muted dark:disabled:bg-background",
14
- "disabled:border-edge/50",
15
- "disabled:text-foreground/30",
16
+ fieldFocus,
17
+ fieldDisabled,
16
18
  "disabled:placeholder:text-muted-foreground/50",
17
- "disabled:cursor-not-allowed",
18
19
  "transition-colors",
19
20
  ].join(" "),
20
21
  {
@@ -42,8 +43,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
42
43
  ref={ref}
43
44
  className={cn(
44
45
  inputVariants({ size }),
45
- error &&
46
- "border-error hover:border-error focus-visible:border-error dark:focus-visible:border-error",
46
+ error && fieldError,
47
47
  className,
48
48
  )}
49
49
  aria-invalid={error || undefined}
@@ -4,16 +4,24 @@ import { Command } from "cmdk";
4
4
  import { cva, type VariantProps } from "class-variance-authority";
5
5
  import { CaretDown, Check, X } from "@phosphor-icons/react";
6
6
  import { cn } from "@ac/lib/cn";
7
+ import {
8
+ fieldChassis,
9
+ fieldError,
10
+ fieldFocus,
11
+ fieldTriggerHover,
12
+ } from "@ac/lib/field-chassis";
7
13
 
8
14
  const triggerVariants = cva(
9
15
  [
10
16
  "inline-flex items-center gap-1.5",
11
17
  "w-full font-sans text-foreground",
12
- "bg-background dark:bg-surface",
13
- "border border-edge rounded-sm",
14
- "hover:border-edge-hover",
15
- "focus-visible:outline-none",
16
- "focus-visible:border-accent-700 dark:focus-visible:border-accent",
18
+ fieldChassis,
19
+ fieldTriggerHover,
20
+ fieldFocus,
21
+ // MultiSelect's trigger is a role="button" div, not a native disabled
22
+ // control — it uses aria-disabled: instead of the shared fieldDisabled
23
+ // (disabled:) constant. Kept component-side; see docs/ARCHITECTURE.md
24
+ // § Shared Chassis Constants (`field-chassis.ts`).
17
25
  "aria-disabled:bg-muted dark:aria-disabled:bg-background",
18
26
  "aria-disabled:border-edge/50",
19
27
  "aria-disabled:text-foreground/30",
@@ -141,8 +149,7 @@ const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
141
149
  className={cn(
142
150
  triggerVariants({ size }),
143
151
  "cursor-pointer",
144
- error &&
145
- "border-error hover:border-error focus-visible:border-error dark:focus-visible:border-error",
152
+ error && fieldError,
146
153
  !hasSelection &&
147
154
  (disabled
148
155
  ? "text-muted-foreground/50"
@@ -0,0 +1,103 @@
1
+ import { forwardRef, useRef, type InputHTMLAttributes } from "react";
2
+ import { CaretDown, CaretUp } from "@phosphor-icons/react";
3
+ import { cn } from "@ac/lib/cn";
4
+ import { fieldChassis } from "@ac/lib/field-chassis";
5
+
6
+ type NumberInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size"> & {
7
+ size?: "sm" | "md";
8
+ error?: boolean;
9
+ };
10
+
11
+ const sizeClasses = {
12
+ sm: "py-1.5 pl-4 text-sm",
13
+ md: "py-2 pl-5 text-base",
14
+ };
15
+
16
+ const stepButtonClasses =
17
+ "flex items-center justify-center cursor-pointer transition-colors text-foreground/60 hover:text-accent disabled:text-foreground/30 disabled:cursor-not-allowed disabled:hover:text-foreground/30";
18
+
19
+ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
20
+ ({ size = "md", error = false, disabled = false, className, ...rest }, ref) => {
21
+ const inputRef = useRef<HTMLInputElement>(null);
22
+
23
+ const setRefs = (node: HTMLInputElement | null) => {
24
+ inputRef.current = node;
25
+ if (typeof ref === "function") {
26
+ ref(node);
27
+ } else if (ref) {
28
+ ref.current = node;
29
+ }
30
+ };
31
+
32
+ const step = (direction: "up" | "down") => {
33
+ const input = inputRef.current;
34
+ if (!input || disabled) return;
35
+ if (direction === "up") {
36
+ input.stepUp();
37
+ } else {
38
+ input.stepDown();
39
+ }
40
+ input.dispatchEvent(new Event("change", { bubbles: true }));
41
+ };
42
+
43
+ return (
44
+ <div
45
+ className={cn(
46
+ "flex w-full items-center font-sans text-foreground",
47
+ fieldChassis,
48
+ "transition-colors",
49
+ "focus-within:border-accent-700 dark:focus-within:border-accent",
50
+ disabled &&
51
+ "bg-muted dark:bg-background border-edge/50 cursor-not-allowed",
52
+ error && [
53
+ "border-error",
54
+ "focus-within:border-error dark:focus-within:border-error",
55
+ ],
56
+ sizeClasses[size],
57
+ className,
58
+ )}
59
+ >
60
+ <input
61
+ ref={setRefs}
62
+ disabled={disabled}
63
+ className={cn(
64
+ "w-full bg-transparent focus-visible:outline-none",
65
+ "[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
66
+ "placeholder:text-muted-foreground",
67
+ "disabled:placeholder:text-muted-foreground/50",
68
+ disabled && "text-foreground/30 cursor-not-allowed",
69
+ )}
70
+ aria-invalid={error || undefined}
71
+ {...rest}
72
+ type="number"
73
+ />
74
+ <div className="flex flex-col pr-1 -my-0.5">
75
+ <button
76
+ type="button"
77
+ tabIndex={-1}
78
+ disabled={disabled}
79
+ aria-label="Increase value"
80
+ className={stepButtonClasses}
81
+ onClick={() => step("up")}
82
+ >
83
+ <CaretUp weight="bold" className="size-3" />
84
+ </button>
85
+ <button
86
+ type="button"
87
+ tabIndex={-1}
88
+ disabled={disabled}
89
+ aria-label="Decrease value"
90
+ className={stepButtonClasses}
91
+ onClick={() => step("down")}
92
+ >
93
+ <CaretDown weight="bold" className="size-3" />
94
+ </button>
95
+ </div>
96
+ </div>
97
+ );
98
+ },
99
+ );
100
+
101
+ NumberInput.displayName = "NumberInput";
102
+
103
+ export { NumberInput, type NumberInputProps };
@@ -3,20 +3,22 @@ import { Select as SelectPrimitive } from "radix-ui";
3
3
  import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { CaretDown, Check } from "@phosphor-icons/react";
5
5
  import { cn } from "@ac/lib/cn";
6
+ import {
7
+ fieldChassis,
8
+ fieldDisabled,
9
+ fieldError,
10
+ fieldFocus,
11
+ fieldTriggerHover,
12
+ } from "@ac/lib/field-chassis";
6
13
 
7
14
  const triggerVariants = cva(
8
15
  [
9
16
  "inline-flex items-center justify-between",
10
17
  "w-full font-sans text-foreground",
11
- "bg-background dark:bg-surface",
12
- "border border-edge rounded-sm",
13
- "hover:border-edge-hover",
14
- "focus-visible:outline-none",
15
- "focus-visible:border-accent-700 dark:focus-visible:border-accent",
16
- "disabled:bg-muted dark:disabled:bg-background",
17
- "disabled:border-edge/50",
18
- "disabled:text-foreground/30",
19
- "disabled:cursor-not-allowed",
18
+ fieldChassis,
19
+ fieldTriggerHover,
20
+ fieldFocus,
21
+ fieldDisabled,
20
22
  "transition-colors",
21
23
  "data-[placeholder]:text-muted-foreground",
22
24
  "disabled:data-[placeholder]:text-muted-foreground/50",
@@ -76,8 +78,7 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
76
78
  aria-invalid={error || undefined}
77
79
  className={cn(
78
80
  triggerVariants({ size }),
79
- error &&
80
- "border-error hover:border-error focus-visible:border-error dark:focus-visible:border-error",
81
+ error && fieldError,
81
82
  className,
82
83
  )}
83
84
  >
@@ -1,20 +1,21 @@
1
1
  import { forwardRef, type TextareaHTMLAttributes } from "react";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { cn } from "@ac/lib/cn";
4
+ import {
5
+ fieldChassis,
6
+ fieldDisabled,
7
+ fieldError,
8
+ fieldFocus,
9
+ } from "@ac/lib/field-chassis";
4
10
 
5
11
  const textareaVariants = cva(
6
12
  [
7
13
  "w-full font-sans text-foreground",
8
- "bg-background dark:bg-surface",
9
- "border border-edge rounded-sm",
14
+ fieldChassis,
10
15
  "placeholder:text-muted-foreground",
11
- "focus-visible:outline-none",
12
- "focus-visible:border-accent-700 dark:focus-visible:border-accent",
13
- "disabled:bg-muted dark:disabled:bg-background",
14
- "disabled:border-edge/50",
15
- "disabled:text-foreground/30",
16
+ fieldFocus,
17
+ fieldDisabled,
16
18
  "disabled:placeholder:text-muted-foreground/50",
17
- "disabled:cursor-not-allowed",
18
19
  "resize-y transition-colors",
19
20
  ].join(" "),
20
21
  {
@@ -46,8 +47,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
46
47
  rows={rows}
47
48
  className={cn(
48
49
  textareaVariants({ size }),
49
- error &&
50
- "border-error hover:border-error focus-visible:border-error dark:focus-visible:border-error",
50
+ error && fieldError,
51
51
  className,
52
52
  )}
53
53
  aria-invalid={error || undefined}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared "flat slot" chassis classes for form controls (Input, Textarea, and
3
+ * the Select / Combobox / MultiSelect triggers). Extracted from the subsets
4
+ * that are genuinely identical across those five components — see
5
+ * docs/SKIN-PRINCIPLES.md for the underlying rules these encode.
6
+ *
7
+ * Not every component consumes every constant; some classes stay
8
+ * component-side because they diverge (e.g. MultiSelect is a `role="button"`
9
+ * div and uses `aria-disabled:` instead of `disabled:`, so it does not
10
+ * consume `fieldDisabled`).
11
+ */
12
+
13
+ /** Rest-state surface: fill + hairline border + radius floor. */
14
+ export const fieldChassis =
15
+ "bg-background dark:bg-surface border border-edge rounded-sm";
16
+
17
+ /** Cyan hairline focus pair. */
18
+ export const fieldFocus =
19
+ "focus-visible:outline-none focus-visible:border-accent-700 dark:focus-visible:border-accent";
20
+
21
+ /**
22
+ * Flat-sink disabled cluster for controls using the native `disabled:`
23
+ * pseudo-class (Input, Textarea, Select, Combobox).
24
+ */
25
+ export const fieldDisabled =
26
+ "disabled:bg-muted dark:disabled:bg-background disabled:border-edge/50 disabled:text-foreground/30 disabled:cursor-not-allowed";
27
+
28
+ /** Error rail applied on top of the chassis when a control is invalid. */
29
+ export const fieldError =
30
+ "border-error hover:border-error focus-visible:border-error dark:focus-visible:border-error";
31
+
32
+ /** Dropdown-trigger hover cue (Select, Combobox, MultiSelect only). */
33
+ export const fieldTriggerHover = "hover:border-edge-hover";