aplance-ui 1.4.0 → 1.5.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/icon/icon.tsx +4 -6
  3. package/registry/components/primitives/modal/modal.module.css +5 -0
  4. package/registry/components/primitives/modal/modal.tsx +32 -34
  5. package/registry/components/primitives/popover/popover.module.css +4 -2
  6. package/registry/components/primitives/popover/popover.tsx +16 -17
  7. package/registry/components/ui/accordion/accordion.module.css +52 -0
  8. package/registry/components/ui/accordion/accordion.tsx +97 -25
  9. package/registry/components/ui/avatar/avatar.tsx +2 -2
  10. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  11. package/registry/components/ui/button/button.module.css +43 -5
  12. package/registry/components/ui/button/button.tsx +2 -1
  13. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  14. package/registry/components/ui/data-table/data-table.module.css +68 -25
  15. package/registry/components/ui/data-table/data-table.tsx +1238 -1229
  16. package/registry/components/ui/data-table/filters.tsx +21 -0
  17. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  18. package/registry/components/ui/data-table/types.ts +11 -1
  19. package/registry/components/ui/data-table/useDataTable.tsx +1 -2
  20. package/registry/components/ui/data-table/utils.ts +4 -3
  21. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  22. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  23. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +115 -84
  24. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +63 -11
  25. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +51 -55
  26. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  27. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  28. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  29. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  30. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  31. package/registry/components/ui/scroll-rail/scroll-rail.module.css +104 -0
  32. package/registry/components/ui/scroll-rail/scroll-rail.tsx +159 -0
  33. package/registry/components/ui/tabs/tabs.module.css +8 -12
  34. package/registry/components/ui/tabs/tabs.tsx +75 -44
  35. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  36. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  37. package/registry/lib/string.ts +3 -3
  38. package/registry/registry.json +33 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aplance-ui",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "The aplance-ui CLI - scaffold the design system and copy components into your project as source.",
5
5
  "keywords": [
6
6
  "react",
@@ -31,12 +31,10 @@ export const Icon: FC<IconProps> = ({
31
31
  className={clsx(
32
32
  s.wrapper,
33
33
  s[size],
34
- {
35
- [s.disabled]: disabled,
36
- [s.clickable]: !!onClick || clickable,
37
- [s.rotate]: rotate,
38
- [s.rotate_sub]: rotateSub,
39
- },
34
+ disabled && s.disabled,
35
+ (!!onClick || clickable) && s.clickable,
36
+ rotate && s.rotate,
37
+ rotateSub && s.rotate_sub,
40
38
  className,
41
39
  )}
42
40
  style={style}
@@ -21,6 +21,11 @@
21
21
  opacity: 0;
22
22
  transition: opacity var(--modal-animation-duration) var(--ease-in-out);
23
23
 
24
+ /* Closing, it fades out without taking clicks: the page under it answers. */
25
+ &[data-state='closed'] {
26
+ pointer-events: none;
27
+ }
28
+
24
29
  &[data-state='open'] {
25
30
  opacity: 1;
26
31
  }
@@ -207,23 +207,23 @@ export const Modal: FC<ModalProps> = ({
207
207
  const contentRef = useRef<HTMLDivElement>(null);
208
208
  const modalId = useId();
209
209
 
210
+ // Freeze dialog content during close animation so conditional content
211
+ // (e.g. {editAgent && <form>}) doesn't collapse before the animation ends:
212
+ // open, the dialog follows its children; closed, it keeps the last ones.
213
+ const [openChildren, setOpenChildren] = useState(children);
214
+ if (isOpen && openChildren !== children) setOpenChildren(children);
215
+
210
216
  // Split children: <ModalTrigger> siblings render outside the dialog so they
211
217
  // stay visible while closed; everything else is dialog content. Order is
212
218
  // preserved within each bucket, which is all consumers care about.
213
219
  const nestedTriggers: ReactNode[] = [];
214
- const dialogChildren: ReactNode[] = [];
215
220
  Children.forEach(children, (child) => {
216
- if (isValidElement(child) && child.type === ModalTrigger) {
217
- nestedTriggers.push(child);
218
- } else {
219
- dialogChildren.push(child);
220
- }
221
+ if (isValidElement(child) && child.type === ModalTrigger) nestedTriggers.push(child);
222
+ });
223
+ const dialogChildren: ReactNode[] = [];
224
+ Children.forEach(isOpen ? children : openChildren, (child) => {
225
+ if (!(isValidElement(child) && child.type === ModalTrigger)) dialogChildren.push(child);
221
226
  });
222
-
223
- // Freeze dialog content during close animation so conditional content
224
- // (e.g. {editAgent && <form>}) doesn't collapse before the animation ends.
225
- const childrenSnapshot = useRef<ReactNode>(dialogChildren);
226
- if (isOpen) childrenSnapshot.current = dialogChildren;
227
227
 
228
228
  // Stash callbacks in refs so identity changes on the parent don't retrigger
229
229
  // the open/close effect (which would leak timers and re-fire onOpen).
@@ -246,32 +246,30 @@ export const Modal: FC<ModalProps> = ({
246
246
  const close = useCallback(() => setIsOpen(false), [setIsOpen]);
247
247
  const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen, setIsOpen]);
248
248
 
249
- // Open: setShouldRender(true) → node mounts → useLayoutEffect below fires → isVisible=true → open transition
250
- // Close: setIsVisible(false) immediately → close transition plays → setTimeout unmounts after duration
249
+ // Open: the node mounts in this render → useLayoutEffect below fires → isVisible=true → open transition
250
+ // Close: isVisible=false in this render → close transition plays → setTimeout unmounts after duration
251
+ if (isOpen && !shouldRender) setShouldRender(true);
252
+ if (!isOpen && isVisible) setIsVisible(false);
251
253
  useEffect(() => {
252
- if (isOpen) {
253
- setShouldRender(true);
254
- } else {
255
- setIsVisible(false);
256
- const t = setTimeout(() => {
257
- setShouldRender(false);
258
- onCloseRef.current?.();
259
- }, animationDuration);
260
- return () => clearTimeout(t);
261
- }
254
+ if (isOpen) return undefined;
255
+ const t = setTimeout(() => {
256
+ setShouldRender(false);
257
+ onCloseRef.current?.();
258
+ }, animationDuration);
259
+ return () => clearTimeout(t);
262
260
  }, [isOpen, animationDuration]);
263
261
 
264
- // Fires synchronously after the node is painted for the first time.
265
- // Force-reads offsetHeight to flush the browser's style recalc so it
266
- // registers the closed CSS state before we flip to open - same trick
267
- // Popover uses with isMeasured + ResizeObserver.
262
+ // Fires synchronously once the node is painted, and again when it is opened
263
+ // while still mounted (reopened during its close transition, which would
264
+ // otherwise stay closed). Force-reads offsetHeight to flush the browser's
265
+ // style recalc so it registers the closed CSS state before we flip to open -
266
+ // same trick Popover uses with isMeasured + ResizeObserver.
268
267
  useLayoutEffect(() => {
269
- if (shouldRender && mounted && contentRef.current) {
268
+ if (shouldRender && mounted && isOpen && contentRef.current) {
270
269
  void contentRef.current.offsetHeight; // flush reflow
271
- setIsVisible(isOpen);
270
+ setIsVisible(true);
272
271
  }
273
- // eslint-disable-next-line react-hooks/exhaustive-deps
274
- }, [shouldRender, mounted]); // only run when node mounts or mounted state changes
272
+ }, [shouldRender, mounted, isOpen]);
275
273
 
276
274
  useEffect(() => {
277
275
  if (isOpen) onOpenRef.current?.();
@@ -313,7 +311,7 @@ export const Modal: FC<ModalProps> = ({
313
311
 
314
312
  const trap = (e: globalThis.KeyboardEvent) => {
315
313
  if (e.key !== "Tab") return;
316
- if (focusable.length === 0) {
314
+ if (!first || !last) {
317
315
  e.preventDefault();
318
316
  return;
319
317
  }
@@ -388,7 +386,7 @@ export const Modal: FC<ModalProps> = ({
388
386
  style={style}
389
387
  >
390
388
  {/* children render ONLY here - never outside the dialog */}
391
- {childrenSnapshot.current}
389
+ {dialogChildren}
392
390
  </div>
393
391
  </div>
394
392
  ) : null;
@@ -547,7 +545,7 @@ export const ModalHeader: FC<ComponentProps<"div">> = ({
547
545
  {showCloseButton && (
548
546
  <Button
549
547
  as="button"
550
- variant="ghost"
548
+ variant="bordered"
551
549
  layout="box"
552
550
  size="sm"
553
551
  className={s.close_button}
@@ -7,8 +7,10 @@
7
7
  justify-content: center;
8
8
 
9
9
  .trigger {
10
- width: 100%;
11
- min-width: fit-content;
10
+ width: 100%;
11
+ /* At least as wide as its content, unless the host sets the variable (a
12
+ form field sets 0, so a long value truncates). */
13
+ min-width: var(--popover-trigger-min-width, fit-content);
12
14
  display: flex;
13
15
  align-items: center;
14
16
 
@@ -232,11 +232,11 @@ export const Popover: FC<PopoverProps> = ({
232
232
  // in PopoverMenu, so we DON'T reset isMeasured here on close - that would lose
233
233
  // the position cache and break re-opens that interrupt the exit animation.
234
234
  // isMeasured is reset only when the menu actually unmounts (cleanup below).
235
+ // Open mounts the menu in this render; close unmounts it once the exit
236
+ // animation has played.
237
+ if (isOpen && !shouldRender) setShouldRender(true);
235
238
  useEffect(() => {
236
- if (isOpen) {
237
- setShouldRender(true);
238
- return;
239
- }
239
+ if (isOpen) return;
240
240
  const timer = setTimeout(() => {
241
241
  setShouldRender(false);
242
242
  if (onClose) onClose();
@@ -247,21 +247,20 @@ export const Popover: FC<PopoverProps> = ({
247
247
  // Measure after menu is rendered. Cleanup resets isMeasured so the next mount
248
248
  // re-runs the entry animation from a known starting state.
249
249
  useIsomorphicLayoutEffect(() => {
250
- if (shouldRender && menuRef.current) {
251
- calculatePosition();
252
-
253
- if (typeof ResizeObserver === "undefined") {
254
- return () => setIsMeasured(false);
255
- }
250
+ if (!shouldRender || !menuRef.current) return;
251
+ calculatePosition();
256
252
 
257
- const observer = new ResizeObserver(() => calculatePosition());
258
- observer.observe(menuRef.current);
259
- if (popoverRef.current) observer.observe(popoverRef.current);
260
- return () => {
261
- observer.disconnect();
262
- setIsMeasured(false);
263
- };
253
+ if (typeof ResizeObserver === "undefined") {
254
+ return () => setIsMeasured(false);
264
255
  }
256
+
257
+ const observer = new ResizeObserver(() => calculatePosition());
258
+ observer.observe(menuRef.current);
259
+ if (popoverRef.current) observer.observe(popoverRef.current);
260
+ return () => {
261
+ observer.disconnect();
262
+ setIsMeasured(false);
263
+ };
265
264
  }, [shouldRender, mounted, calculatePosition]);
266
265
 
267
266
  // Clear any pending hover-close timer when the Popover unmounts so the
@@ -110,6 +110,58 @@
110
110
  padding: var(--spacing-5) var(--spacing-6);
111
111
  }
112
112
 
113
+ /* A heading with controls beside its button (an item's actions, a toggle
114
+ button): the row takes the heading's padding and its shades, and the
115
+ button inside it takes what is left. */
116
+ .header {
117
+ display: flex;
118
+ align-items: center;
119
+ gap: var(--spacing-3);
120
+ width: 100%;
121
+ }
122
+
123
+ .header_trigger {
124
+ flex: 1;
125
+ min-width: 0;
126
+ padding: 0;
127
+ background: transparent;
128
+ border-radius: var(--radius-sm);
129
+
130
+ &:focus-visible {
131
+ box-shadow: var(--shadow-ring);
132
+ }
133
+ }
134
+
135
+ .header_bordered,
136
+ .header_minimal {
137
+ &:has(> .header_trigger:hover:not(:disabled)) {
138
+ background: var(--color-muted);
139
+ }
140
+ }
141
+
142
+ .header_minimal {
143
+ border-radius: inherit;
144
+ }
145
+
146
+ .actions {
147
+ flex-shrink: 0;
148
+ display: flex;
149
+ align-items: center;
150
+ gap: var(--spacing-2);
151
+ }
152
+
153
+ .toggle {
154
+ flex-shrink: 0;
155
+
156
+ & svg {
157
+ transition: transform var(--transition-base);
158
+ }
159
+
160
+ &.toggle_open svg {
161
+ transform: rotate(var(--accordion-icon-rotation, 180deg));
162
+ }
163
+ }
164
+
113
165
  .title {
114
166
  flex: 1;
115
167
  min-width: 0;
@@ -9,6 +9,7 @@ import {
9
9
  import clsx from "clsx";
10
10
  import { IconChevronDown } from "@tabler/icons-react";
11
11
  import { useResizeObserver } from "../../../hooks/useResizeObserver";
12
+ import { Button } from "../button/button";
12
13
  import s from "./accordion.module.css";
13
14
 
14
15
  export type AccordionVariant = "bordered" | "minimal";
@@ -17,12 +18,23 @@ export type AccordionMode = "single" | "multiple";
17
18
 
18
19
  export type AccordionSize = "sm" | "md" | "lg";
19
20
 
21
+ /**
22
+ * How an item opens. icon: the whole heading, with a chevron in it. button:
23
+ * the heading too, with a bordered chevron button at its end, as a table row
24
+ * ends in its menu button.
25
+ */
26
+ export type AccordionToggle = "icon" | "button";
27
+
20
28
  export type AccordionItem = {
21
29
  id: string;
22
30
  title: ReactNode;
23
31
  content: ReactNode;
24
32
  disabled?: boolean;
25
33
  defaultOpen?: boolean;
34
+ /** Controls beside the heading, outside the button that opens it: a row's menu. */
35
+ actions?: ReactNode;
36
+ /** This item's own class, beside `itemClassName`: one marked apart from the rest. */
37
+ className?: string;
26
38
  };
27
39
 
28
40
  type AccordionBaseProps = {
@@ -35,6 +47,7 @@ type AccordionBaseProps = {
35
47
  size?: AccordionSize;
36
48
  icon?: ReactNode;
37
49
  iconRotation?: number;
50
+ toggle?: AccordionToggle;
38
51
  };
39
52
 
40
53
  type AccordionSingleProps = {
@@ -80,6 +93,7 @@ type ItemViewProps = {
80
93
  size: AccordionSize;
81
94
  variant: AccordionVariant;
82
95
  icon?: ReactNode;
96
+ toggle: AccordionToggle;
83
97
  itemClassName?: string;
84
98
  triggerClassName?: string;
85
99
  contentClassName?: string;
@@ -114,6 +128,7 @@ const AccordionItemView = ({
114
128
  size,
115
129
  variant,
116
130
  icon,
131
+ toggle,
117
132
  itemClassName,
118
133
  triggerClassName,
119
134
  contentClassName,
@@ -138,6 +153,13 @@ const AccordionItemView = ({
138
153
 
139
154
  useResizeObserver(contentRef, updateHeight, { enabled: isOpen });
140
155
 
156
+ const chevron = icon ?? (
157
+ <IconChevronDown size={size === "sm" ? 16 : size === "lg" ? 20 : 18} />
158
+ );
159
+ // With actions or a toggle button, the heading is a row: the button that
160
+ // opens the item, then the controls beside it, outside that button.
161
+ const row = toggle === "button" || item.actions !== undefined;
162
+
141
163
  return (
142
164
  <section
143
165
  className={clsx(
@@ -146,42 +168,90 @@ const AccordionItemView = ({
146
168
  isOpen && s.item_open,
147
169
  item.disabled && s.item_disabled,
148
170
  itemClassName,
171
+ item.className,
149
172
  )}
150
173
  data-state={isOpen ? "open" : "closed"}
151
174
  >
152
- <button
153
- id={triggerId}
154
- type="button"
155
- disabled={item.disabled}
156
- aria-expanded={isOpen}
157
- aria-controls={panelId}
158
- onClick={onToggle}
159
- className={clsx(
160
- s.trigger,
161
- s[`trigger_${size}`],
162
- s[`trigger_${variant}`],
163
- isOpen && s.trigger_open,
164
- triggerClassName,
165
- )}
166
- >
167
- <span className={clsx(s.title, s[`title_${size}`])}>{item.title}</span>
168
- <span
169
- className={clsx(s.icon, isOpen && s.icon_open)}
170
- aria-hidden="true"
175
+ {row ? (
176
+ <div
177
+ className={clsx(
178
+ s.header,
179
+ s[`trigger_${size}`],
180
+ s[`header_${variant}`],
181
+ isOpen && s.trigger_open,
182
+ )}
171
183
  >
172
- {icon ?? (
173
- <IconChevronDown
174
- size={size === "sm" ? 16 : size === "lg" ? 20 : 18}
175
- />
184
+ <button
185
+ id={triggerId}
186
+ type="button"
187
+ disabled={item.disabled}
188
+ aria-expanded={isOpen}
189
+ aria-controls={panelId}
190
+ onClick={onToggle}
191
+ className={clsx(s.trigger, s.header_trigger, triggerClassName)}
192
+ >
193
+ <span className={clsx(s.title, s[`title_${size}`])}>{item.title}</span>
194
+ {toggle === "icon" ? (
195
+ <span
196
+ className={clsx(s.icon, isOpen && s.icon_open)}
197
+ aria-hidden="true"
198
+ >
199
+ {chevron}
200
+ </span>
201
+ ) : null}
202
+ </button>
203
+ {item.actions !== undefined ? (
204
+ <div className={s.actions}>{item.actions}</div>
205
+ ) : null}
206
+ {toggle === "button" ? (
207
+ <Button
208
+ type="button"
209
+ variant="bordered"
210
+ size="sm"
211
+ layout="box"
212
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
213
+ aria-expanded={isOpen}
214
+ aria-controls={panelId}
215
+ disabled={item.disabled}
216
+ onClick={onToggle}
217
+ className={clsx(s.toggle, isOpen && s.toggle_open)}
218
+ >
219
+ {chevron}
220
+ </Button>
221
+ ) : null}
222
+ </div>
223
+ ) : (
224
+ <button
225
+ id={triggerId}
226
+ type="button"
227
+ disabled={item.disabled}
228
+ aria-expanded={isOpen}
229
+ aria-controls={panelId}
230
+ onClick={onToggle}
231
+ className={clsx(
232
+ s.trigger,
233
+ s[`trigger_${size}`],
234
+ s[`trigger_${variant}`],
235
+ isOpen && s.trigger_open,
236
+ triggerClassName,
176
237
  )}
177
- </span>
178
- </button>
238
+ >
239
+ <span className={clsx(s.title, s[`title_${size}`])}>{item.title}</span>
240
+ <span
241
+ className={clsx(s.icon, isOpen && s.icon_open)}
242
+ aria-hidden="true"
243
+ >
244
+ {chevron}
245
+ </span>
246
+ </button>
247
+ )}
179
248
 
180
249
  <div
181
250
  id={panelId}
182
251
  role="region"
183
252
  aria-labelledby={triggerId}
184
253
  aria-hidden={!isOpen}
254
+ inert={!isOpen}
185
255
  className={clsx(s.panel, isOpen && s.panel_open)}
186
256
  style={{ height: `${panelHeight}px` }}
187
257
  >
@@ -212,6 +282,7 @@ export const Accordion = ({
212
282
  size = "md",
213
283
  icon,
214
284
  iconRotation = 180,
285
+ toggle = "icon",
215
286
  ...variantProps
216
287
  }: AccordionProps) => {
217
288
  const uid = useId();
@@ -317,6 +388,7 @@ export const Accordion = ({
317
388
  size={size}
318
389
  variant={variant}
319
390
  icon={icon}
391
+ toggle={toggle}
320
392
  itemClassName={itemClassName}
321
393
  triggerClassName={triggerClassName}
322
394
  contentClassName={contentClassName}
@@ -17,10 +17,10 @@ const COLORS = [
17
17
  { bg: "oklch(0.92 0.06 85)", fg: "oklch(0.4 0.15 85)" },
18
18
  { bg: "oklch(0.92 0.05 25)", fg: "oklch(0.4 0.15 25)" },
19
19
  { bg: "oklch(0.92 0.05 330)", fg: "oklch(0.4 0.15 330)" },
20
- ];
20
+ ] as const;
21
21
 
22
22
  export function Avatar({ name, size = "md", className }: AvatarProps) {
23
- const color = COLORS[hashName(name) % COLORS.length];
23
+ const color = COLORS[hashName(name) % COLORS.length] ?? COLORS[0];
24
24
  const initials = getInitials(name) || "?";
25
25
  return (
26
26
  <div
@@ -9,8 +9,7 @@
9
9
  --breadcrumbs-color-current: var(--color-foreground);
10
10
  --breadcrumbs-color-separator: var(--color-muted-foreground);
11
11
  --breadcrumbs-font-size: var(--font-size-sm);
12
- --breadcrumbs-gap: var(--spacing-3);
13
- --breadcrumbs-padding: var(--spacing-2) var(--spacing-3);
12
+ --breadcrumbs-gap: var(--spacing-5);
14
13
 
15
14
  display: block;
16
15
  width: 100%;
@@ -22,8 +21,7 @@
22
21
 
23
22
  .size_sm {
24
23
  --breadcrumbs-font-size: var(--font-size-xs);
25
- --breadcrumbs-gap: var(--spacing-2);
26
- --breadcrumbs-padding: var(--spacing-1) var(--spacing-2);
24
+ --breadcrumbs-gap: var(--spacing-4);
27
25
  }
28
26
 
29
27
  /* ─── List ───────────────────────────────────────────────────────────────── */
@@ -45,15 +43,18 @@
45
43
  min-width: 0;
46
44
  }
47
45
 
48
- /* ─── Crumb (link / button / span) ───────────────────────────────────────── */
46
+ /* ─── Crumb (link / button / span) ───────────────────────────────────────────
47
+ A text link, not a button: no padding or hover fill, so every crumb sits on
48
+ the same edge as the content under it. Hover darkens the text.
49
+ ─────────────────────────────────────────────────────────────────────────── */
49
50
 
50
51
  .crumb {
51
52
  display: inline-flex;
52
53
  align-items: center;
53
54
  gap: var(--spacing-2);
54
55
  min-width: 0;
55
- padding: var(--breadcrumbs-padding);
56
- border-radius: var(--radius-md);
56
+ padding: 0;
57
+ border-radius: var(--radius-sm);
57
58
  color: inherit;
58
59
  background-color: transparent;
59
60
  font: inherit;
@@ -62,7 +63,7 @@
62
63
  white-space: nowrap;
63
64
  cursor: default;
64
65
  transition: var(--transition-fast);
65
- transition-property: background-color, color;
66
+ transition-property: color;
66
67
  }
67
68
 
68
69
  .crumb_link {
@@ -71,7 +72,6 @@
71
72
 
72
73
  &:hover {
73
74
  color: var(--breadcrumbs-color-hover);
74
- background-color: var(--color-muted);
75
75
  }
76
76
 
77
77
  &:focus-visible {
@@ -1,4 +1,4 @@
1
- .base {
1
+ .base {
2
2
  --button-fg: var(--color-foreground);
3
3
  --button-placeholder: var(--button-fg);
4
4
  --button-icon: var(--button-fg);
@@ -11,7 +11,9 @@
11
11
  min-height: var(--spacing-11);
12
12
  border-radius: var(--radius-md);
13
13
  display: flex;
14
- min-width: fit-content;
14
+ /* Never narrower than its label, unless the host sets the variable (a
15
+ full-width field trigger sets 0, so its label truncates). */
16
+ min-width: var(--button-min-width, fit-content);
15
17
  align-items: center;
16
18
  justify-content: space-between;
17
19
  gap: var(--spacing-4);
@@ -24,7 +26,7 @@
24
26
  outline: none;
25
27
  border: none;
26
28
 
27
- line-height: 1.125;
29
+ line-height: 1.125;
28
30
 
29
31
  svg {
30
32
  cursor: pointer;
@@ -36,12 +38,16 @@
36
38
  font-size: var(--font-size-xs);
37
39
  }
38
40
 
39
- &.box {
41
+ /* An icon button: as wide as it is high, its own size and never its
42
+ content's (the base keeps a button at least as wide as its content), so
43
+ a round one stays round; the icon sits in its middle. */
44
+ &.box {
40
45
  width: var(--spacing-11);
46
+ min-width: 0;
41
47
  min-height: unset;
42
48
  justify-content: center;
43
49
  height: var(--spacing-11);
44
- padding: var(--spacing-2);
50
+ padding: 0;
45
51
 
46
52
  &.sm {
47
53
  width: var(--spacing-10);
@@ -183,6 +189,38 @@
183
189
  }
184
190
  }
185
191
 
192
+ /* Same pattern - danger is the soft red a Badge's error tone uses, for an
193
+ action that cannot be taken back: deleting, purging, removing. It reads in
194
+ both themes from the error tokens alone. */
195
+ :is(.danger, .tone_danger) {
196
+ --button-fg: var(--color-error-foreground);
197
+ --button-placeholder: var(--color-error-foreground);
198
+ --button-icon: var(--color-error-foreground);
199
+ }
200
+
201
+ .danger {
202
+ background-color: var(--color-error-subtle);
203
+ border: 1px solid
204
+ color-mix(in oklch, var(--color-error-foreground) 24%, var(--color-border));
205
+ color: var(--button-fg);
206
+ box-shadow: var(--shadow-sm);
207
+
208
+ svg {
209
+ color: var(--button-icon);
210
+ }
211
+
212
+ &:hover:not(.disabled),
213
+ &:active:not(.disabled),
214
+ &.input_focus:focus,
215
+ &.active {
216
+ background-color: color-mix(
217
+ in oklch,
218
+ var(--color-error-foreground) 14%,
219
+ var(--color-error-subtle)
220
+ );
221
+ }
222
+ }
223
+
186
224
  /* Same pattern - ghost is transparent background; tone alias lets composing
187
225
  components inherit these vars without applying the full ghost layout rules. */
188
226
  :is(.ghost, .tone_ghost) {
@@ -12,7 +12,8 @@ export type ButtonVariant =
12
12
  | "secondary"
13
13
  | "bordered"
14
14
  | "border_b"
15
- | "ghost";
15
+ | "ghost"
16
+ | "danger";
16
17
  export type ButtonSize = "default" | "sm";
17
18
  export type ButtonLayout = "default" | "box";
18
19
  export type AsProp = "button" | "div" | "span" | "link";
@@ -1,6 +1,8 @@
1
+ /* A fixed control: long text beside it, as in a table cell, never squeezes it. */
1
2
  .base {
2
3
  display: inline-flex;
3
4
  align-items: center;
5
+ flex-shrink: 0;
4
6
  }
5
7
 
6
8
  .icon {