@makolabs/ripple 3.0.0 → 3.0.1

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.
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../../helper/cls.js';
3
3
  import { buildTestId } from '../../helper/testid.js';
4
4
  import { Size } from '../../variants.js';
5
+ import { formSizeTokens } from '../../forms/form-size.js';
5
6
  import Popover from '../popover/Popover.svelte';
6
7
  import type { ComboBoxProps, ComboBoxItem } from './combobox-types.js';
7
8
 
@@ -57,16 +58,7 @@
57
58
  if (highlightedIndex >= filteredItems.length) highlightedIndex = 0;
58
59
  });
59
60
 
60
- const sizeClass = $derived(
61
- {
62
- [Size.XS]: 'h-7 text-xs',
63
- [Size.SM]: 'h-8 text-sm',
64
- [Size.MD]: 'h-10 text-sm',
65
- [Size.LG]: 'h-12 text-base',
66
- [Size.XL]: 'h-14 text-lg',
67
- [Size.XXL]: 'h-16 text-lg'
68
- }[size]
69
- );
61
+ const tokens = $derived(formSizeTokens[size]);
70
62
 
71
63
  function openMenu() {
72
64
  if (disabled) return;
@@ -139,9 +131,14 @@
139
131
  <Popover trigger="manual" bind:open panelClass="w-[var(--cbx-w,20rem)] p-0">
140
132
  <div
141
133
  class={cn(
142
- 'flex w-full items-center gap-2 rounded-lg border bg-white px-3 transition-colors',
134
+ 'flex w-full items-center border bg-white transition-colors',
143
135
  'focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none',
144
- sizeClass,
136
+ tokens.height,
137
+ tokens.padX,
138
+ tokens.text,
139
+ tokens.gap,
140
+ tokens.radius,
141
+ tokens.shadow,
145
142
  hasErrors
146
143
  ? 'border-danger-300 focus-within:border-danger-500 focus-within:ring-danger-500'
147
144
  : 'border-default-300 focus-within:border-primary-500 focus-within:ring-primary-500',
@@ -1,45 +1,10 @@
1
1
  export declare const selectTV: import("tailwind-variants").TVReturnType<{
2
2
  size: {
3
- xs: {
3
+ [k: string]: {
4
4
  trigger: string;
5
5
  triggerIcon: string;
6
- container: string;
7
- item: string;
8
- base: string;
9
- };
10
- sm: {
11
- trigger: string;
12
- triggerIcon: string;
13
- container: string;
14
6
  item: string;
15
- base: string;
16
- };
17
- md: {
18
- trigger: string;
19
- triggerIcon: string;
20
7
  container: string;
21
- item: string;
22
- base: string;
23
- };
24
- lg: {
25
- trigger: string;
26
- triggerIcon: string;
27
- container: string;
28
- item: string;
29
- base: string;
30
- };
31
- xl: {
32
- trigger: string;
33
- triggerIcon: string;
34
- container: string;
35
- item: string;
36
- base: string;
37
- };
38
- "2xl": {
39
- trigger: string;
40
- triggerIcon: string;
41
- container: string;
42
- item: string;
43
8
  base: string;
44
9
  };
45
10
  };
@@ -71,46 +36,11 @@ export declare const selectTV: import("tailwind-variants").TVReturnType<{
71
36
  emptyMessage: string;
72
37
  }, undefined, {
73
38
  size: {
74
- xs: {
75
- trigger: string;
76
- triggerIcon: string;
77
- container: string;
78
- item: string;
79
- base: string;
80
- };
81
- sm: {
82
- trigger: string;
83
- triggerIcon: string;
84
- container: string;
85
- item: string;
86
- base: string;
87
- };
88
- md: {
89
- trigger: string;
90
- triggerIcon: string;
91
- container: string;
92
- item: string;
93
- base: string;
94
- };
95
- lg: {
39
+ [k: string]: {
96
40
  trigger: string;
97
41
  triggerIcon: string;
98
- container: string;
99
42
  item: string;
100
- base: string;
101
- };
102
- xl: {
103
- trigger: string;
104
- triggerIcon: string;
105
43
  container: string;
106
- item: string;
107
- base: string;
108
- };
109
- "2xl": {
110
- trigger: string;
111
- triggerIcon: string;
112
- container: string;
113
- item: string;
114
44
  base: string;
115
45
  };
116
46
  };
@@ -142,46 +72,11 @@ export declare const selectTV: import("tailwind-variants").TVReturnType<{
142
72
  emptyMessage: string;
143
73
  }, import("tailwind-variants").TVReturnType<{
144
74
  size: {
145
- xs: {
75
+ [k: string]: {
146
76
  trigger: string;
147
77
  triggerIcon: string;
148
- container: string;
149
78
  item: string;
150
- base: string;
151
- };
152
- sm: {
153
- trigger: string;
154
- triggerIcon: string;
155
79
  container: string;
156
- item: string;
157
- base: string;
158
- };
159
- md: {
160
- trigger: string;
161
- triggerIcon: string;
162
- container: string;
163
- item: string;
164
- base: string;
165
- };
166
- lg: {
167
- trigger: string;
168
- triggerIcon: string;
169
- container: string;
170
- item: string;
171
- base: string;
172
- };
173
- xl: {
174
- trigger: string;
175
- triggerIcon: string;
176
- container: string;
177
- item: string;
178
- base: string;
179
- };
180
- "2xl": {
181
- trigger: string;
182
- triggerIcon: string;
183
- container: string;
184
- item: string;
185
80
  base: string;
186
81
  };
187
82
  };
@@ -1,15 +1,49 @@
1
1
  import { tv } from 'tailwind-variants';
2
2
  import { Size } from '../../variants.js';
3
+ import { formSizeTokens } from '../../forms/form-size.js';
4
+ // Build the per-size slot overrides from the canonical form-size tokens
5
+ // so Select coordinates visually with Input/Textarea/NumberInput at the
6
+ // same `size` prop. Container max-heights and base min-widths stay here
7
+ // because they're Select-specific (not tied to field sizing).
8
+ const containerHeights = {
9
+ [Size.XS]: 'max-h-32',
10
+ [Size.SM]: 'max-h-40',
11
+ [Size.MD]: 'max-h-56',
12
+ [Size.LG]: 'max-h-64',
13
+ [Size.XL]: 'max-h-72',
14
+ [Size.XXL]: 'max-h-80'
15
+ };
16
+ const minWidths = {
17
+ [Size.XS]: 'min-w-20',
18
+ [Size.SM]: 'min-w-28',
19
+ [Size.MD]: 'min-w-32',
20
+ [Size.LG]: 'min-w-40',
21
+ [Size.XL]: 'min-w-48',
22
+ [Size.XXL]: 'min-w-56'
23
+ };
24
+ const sizeVariants = Object.fromEntries(Object.keys(formSizeTokens).map((key) => {
25
+ const t = formSizeTokens[key];
26
+ return [
27
+ key,
28
+ {
29
+ trigger: `${t.height} ${t.padX} ${t.padY} ${t.text} ${t.gap} ${t.radius} ${t.shadow}`,
30
+ triggerIcon: t.iconSize,
31
+ item: `${t.padX} ${t.padY} ${t.text}`,
32
+ container: containerHeights[key],
33
+ base: minWidths[key]
34
+ }
35
+ ];
36
+ }));
3
37
  export const selectTV = tv({
4
38
  slots: {
5
39
  base: 'w-full',
6
40
  trigger: `relative flex items-center justify-between w-full text-left bg-white border
7
- border-default-300 text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:border-primary-500 focus-within:ring-primary-500 rounded-lg shadow-xs cursor-pointer transition-colors hover:border-default-400`,
41
+ border-default-300 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:border-primary-500 focus-within:ring-primary-500 cursor-pointer transition-colors hover:border-default-400`,
8
42
  triggerIcon: 'transition-transform duration-200 text-default-500',
9
43
  container: 'absolute z-50 w-full mt-1 bg-white overflow-clip border border-default-200 rounded-md shadow-sm origin-top-left top-full left-0 mt-2',
10
44
  searchInput: 'flex items-center gap-x-3 w-full outline-none px-2 h-10 border-b border-b-default-200',
11
45
  list: 'py-1 max-h-60 overflow-x-clip overflow-y-auto h-full',
12
- item: `w-full px-3 py-2 text-sm text-left
46
+ item: `w-full text-left
13
47
  data-[highlighted=true]:bg-default-100 data-[highlighted=true]:text-default-700
14
48
  data-[selected=true]:bg-info-100 data-[selected=true]:text-info-500 data-[selected=true]:font-medium
15
49
  data-[selected=true]:data-[highlighted=true]:bg-info-200 data-[selected=true]:data-[highlighted=true]:text-info-500
@@ -17,50 +51,7 @@ export const selectTV = tv({
17
51
  emptyMessage: 'px-3 py-2 text-sm text-default-500'
18
52
  },
19
53
  variants: {
20
- size: {
21
- [Size.XS]: {
22
- trigger: 'h-6 px-2 py-1 text-xs gap-1',
23
- triggerIcon: 'h-3 w-3',
24
- container: 'max-h-40',
25
- item: 'px-2 py-1 text-xs',
26
- base: 'min-w-24'
27
- },
28
- [Size.SM]: {
29
- trigger: 'h-8 px-3 py-2 text-sm gap-1.5',
30
- triggerIcon: 'h-3.5 w-3.5',
31
- container: 'max-h-48',
32
- item: 'px-2.5 py-1.5 text-xs',
33
- base: 'min-w-32'
34
- },
35
- [Size.MD]: {
36
- trigger: 'h-10 px-3 py-2 text-base gap-2',
37
- triggerIcon: 'h-4 w-4',
38
- container: 'max-h-60',
39
- item: 'px-3 py-2 text-sm',
40
- base: 'min-w-40'
41
- },
42
- [Size.LG]: {
43
- trigger: 'h-12 px-3 py-2 text-lg gap-2.5',
44
- triggerIcon: 'h-5 w-5',
45
- container: 'max-h-72',
46
- item: 'px-4 py-2.5 text-base',
47
- base: 'min-w-48'
48
- },
49
- [Size.XL]: {
50
- trigger: 'h-12 px-5 py-3 text-lg gap-3',
51
- triggerIcon: 'h-6 w-6',
52
- container: 'max-h-80',
53
- item: 'px-5 py-3 text-lg',
54
- base: 'min-w-56'
55
- },
56
- [Size.XXL]: {
57
- trigger: 'h-14 px-6 py-3.5 text-xl gap-4',
58
- triggerIcon: 'h-7 w-7',
59
- container: 'max-h-96',
60
- item: 'px-6 py-3.5 text-xl',
61
- base: 'min-w-64'
62
- }
63
- },
54
+ size: sizeVariants,
64
55
  disabled: {
65
56
  true: {
66
57
  trigger: 'opacity-50 cursor-not-allowed hover:border-default-300',
@@ -162,6 +162,26 @@
162
162
  };
163
163
  });
164
164
 
165
+ /**
166
+ * Portal the panel element to `document.body` once it mounts, so any
167
+ * ancestor with `transform` / `filter` / `will-change` (Storybook's
168
+ * docs container, a scaled preview, a card with transform-based
169
+ * hover, etc.) doesn't re-parent our `position: fixed` panel and
170
+ * clip / mis-position it. On unmount, we simply remove the node —
171
+ * Svelte's own `{#if}` teardown will still work because it tracks
172
+ * the element by reference, not by DOM path.
173
+ */
174
+ $effect(() => {
175
+ if (!panelEl) return;
176
+ const el = panelEl;
177
+ document.body.appendChild(el);
178
+ return () => {
179
+ if (el.parentNode === document.body) {
180
+ document.body.removeChild(el);
181
+ }
182
+ };
183
+ });
184
+
165
185
  const arrowClass = $derived(
166
186
  {
167
187
  top: 'top-full left-1/2 -translate-x-1/2 border-t-white border-l-transparent border-r-transparent border-b-transparent',
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { buildTestId } from '../helper/testid.js';
4
- import type { CheckboxProps } from '../index.js';
4
+ import { Size } from '../variants.js';
5
+ import { formSizeTokens } from './form-size.js';
6
+ import type { CheckboxProps, VariantSizes } from '../index.js';
5
7
 
6
8
  let {
7
9
  name,
@@ -10,21 +12,34 @@
10
12
  disabled = false,
11
13
  errors = [],
12
14
  required = false,
15
+ size = Size.MD,
13
16
  testId
14
17
  }: CheckboxProps = $props();
15
18
 
19
+ // Checkbox box dimension uses Tailwind's `size-*` shorthand, scaling
20
+ // from size-3 (XS) up to size-7 (XXL) so it stays visually proportional
21
+ // to adjacent form controls at the same size.
22
+ const boxSize: Record<VariantSizes, string> = {
23
+ [Size.XS]: 'size-3',
24
+ [Size.SM]: 'size-3.5',
25
+ [Size.MD]: 'size-4',
26
+ [Size.LG]: 'size-5',
27
+ [Size.XL]: 'size-6',
28
+ [Size.XXL]: 'size-7'
29
+ };
30
+ const tokens = $derived(formSizeTokens[size]);
31
+
16
32
  const checkboxClass = $derived(
17
- cn('w-4 h-4 rounded text-primary-600 border-default-300 focus:ring-primary-500', {
18
- 'opacity-50 cursor-not-allowed': disabled,
19
- 'accent-danger-500': errors.length
20
- })
33
+ cn(
34
+ 'rounded text-primary-600 border-default-300 focus:ring-primary-500',
35
+ boxSize[size],
36
+ disabled && 'opacity-50 cursor-not-allowed',
37
+ errors.length && 'accent-danger-500'
38
+ )
21
39
  );
22
40
 
23
41
  const labelClass = $derived(
24
- cn('text-sm font-medium', {
25
- 'text-default-700': !errors.length,
26
- 'text-danger-600': errors.length
27
- })
42
+ cn('font-medium', tokens.text, errors.length ? 'text-danger-600' : 'text-default-700')
28
43
  );
29
44
  </script>
30
45
 
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../helper/cls.js';
3
+ import { Size } from '../variants.js';
4
+ import { formSizeTokens } from './form-size.js';
3
5
  import type { DateRangeProps } from '../index.js';
4
6
  import Portal from '../utils/Portal.svelte';
5
7
  import { fly } from 'svelte/transition';
@@ -17,11 +19,14 @@
17
19
  endLabel = 'End date',
18
20
  format = 'MM/dd/yyyy',
19
21
  errors = [],
22
+ size = Size.MD,
20
23
  id,
21
24
  name,
22
25
  onselect
23
26
  }: DateRangeProps = $props();
24
27
 
28
+ const tokens = $derived(formSizeTokens[size]);
29
+
25
30
  let isOpen = $state(false);
26
31
  let hoveredDate = $state<Date | null>(null);
27
32
  let datePickerRef = $state<HTMLDivElement | null>(null);
@@ -239,7 +244,12 @@
239
244
  {id}
240
245
  type="button"
241
246
  class={cn(
242
- 'border-default-300 flex w-full items-center justify-between rounded-lg border bg-white px-3 py-2 text-sm shadow-xs',
247
+ 'border-default-300 flex w-full items-center justify-between border bg-white',
248
+ tokens.height,
249
+ tokens.padX,
250
+ tokens.text,
251
+ tokens.radius,
252
+ tokens.shadow,
243
253
  disabled
244
254
  ? 'bg-default-100 text-default-400 cursor-not-allowed opacity-50'
245
255
  : errors?.length
@@ -259,7 +269,7 @@
259
269
  ? `${formatDate(startDate)} - Select end date`
260
270
  : placeholder}
261
271
  </span>
262
- <svg class="text-default-400 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
272
+ <svg class={cn('text-default-400', tokens.iconSize)} viewBox="0 0 20 20" fill="currentColor">
263
273
  <path
264
274
  fill-rule="evenodd"
265
275
  d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
@@ -271,11 +281,16 @@
271
281
  {#if startDate || endDate}
272
282
  <button
273
283
  type="button"
274
- class="text-default-400 hover:text-default-500 absolute top-1/2 right-10 -translate-y-1/2"
284
+ class={cn(
285
+ 'text-default-400 hover:text-default-500 absolute top-1/2 -translate-y-1/2',
286
+ // Sit just left of the calendar icon; use the token gap
287
+ // so the clear button stays visually tied to the icon.
288
+ 'right-8'
289
+ )}
275
290
  onclick={clearDates}
276
291
  aria-label="Clear dates"
277
292
  >
278
- <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
293
+ <svg class={cn(tokens.iconSize)} viewBox="0 0 20 20" fill="currentColor">
279
294
  <path
280
295
  fill-rule="evenodd"
281
296
  d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { buildTestId } from '../helper/testid.js';
4
4
  import { Size } from '../variants.js';
5
+ import { formSizeTokens } from './form-size.js';
5
6
  import type { InputProps } from '../index.js';
6
7
 
7
8
  let {
@@ -20,26 +21,25 @@
20
21
  }: InputProps = $props();
21
22
 
22
23
  const BASIC_TYPES = ['text', 'email', 'password', 'number', 'tel', 'url', 'date', 'textarea'];
24
+ const tokens = $derived(formSizeTokens[size]);
23
25
  const inputClasses = $derived(
24
26
  cn(
25
- 'transition-colors placeholder:text-default-400',
26
- {
27
- 'border rounded-lg shadow-xs w-full bg-white px-3 py-2 text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2':
28
- BASIC_TYPES.includes(type),
29
- 'w-full bg-white px-3 py-2 text-sm resize-y min-h-[100px]': type === 'textarea',
30
- 'border-danger-300 focus-within:border-danger-500 focus-within:ring-danger-500':
31
- errors.length,
32
- 'opacity-50 cursor-not-allowed': disabled,
33
- ...(BASIC_TYPES.includes(type)
34
- ? {
35
- 'h-8 text-sm': size === Size.SM,
36
- 'h-10 text-base': size === Size.MD,
37
- 'h-12 text-lg': size === Size.LG
38
- }
39
- : {}),
40
- 'border-default-300 focus-within:border-primary-500 focus-within:ring-primary-500':
41
- !errors.length
42
- },
27
+ 'w-full bg-white transition-colors placeholder:text-default-400',
28
+ tokens.padX,
29
+ tokens.text,
30
+ // All basic types (including textarea) get the bordered look.
31
+ BASIC_TYPES.includes(type) && [
32
+ 'border focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2',
33
+ tokens.radius,
34
+ tokens.shadow
35
+ ],
36
+ // Single-line types get a fixed height; textarea is content-driven.
37
+ type !== 'textarea' && tokens.height,
38
+ type === 'textarea' && ['resize-y min-h-[100px]', tokens.padY],
39
+ errors.length
40
+ ? 'border-danger-300 focus-within:border-danger-500 focus-within:ring-danger-500'
41
+ : 'border-default-300 focus-within:border-primary-500 focus-within:ring-primary-500',
42
+ disabled && 'opacity-50 cursor-not-allowed',
43
43
  className
44
44
  )
45
45
  );
@@ -19,7 +19,7 @@
19
19
  labelLayout = 'inline',
20
20
  labelClass = undefined,
21
21
  color = Color.PRIMARY,
22
- size = Size.SM,
22
+ size = Size.MD,
23
23
  compact = false,
24
24
  flagsOnly = false,
25
25
  disabled = false,
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { buildTestId } from '../helper/testid.js';
4
4
  import { Size } from '../variants.js';
5
+ import { formSizeTokens } from './form-size.js';
5
6
  import type { NumberInputProps } from '../index.js';
6
7
 
7
8
  let {
@@ -26,18 +27,16 @@
26
27
 
27
28
  const selectedOption = $derived(units.find((u) => u.value === unit));
28
29
 
30
+ const tokens = $derived(formSizeTokens[size]);
31
+
29
32
  const containerClass = $derived(
30
33
  cn(
31
- 'relative flex items-center gap-1 rounded-lg border bg-white shadow-xs',
32
- {
33
- 'border-danger-300': errors?.length,
34
- 'cursor-not-allowed opacity-50': disabled
35
- },
36
- {
37
- 'h-8': size === Size.SM,
38
- 'h-10': size === Size.MD,
39
- 'h-12': size === Size.LG
40
- },
34
+ 'relative flex items-center gap-1 border bg-white',
35
+ tokens.radius,
36
+ tokens.shadow,
37
+ tokens.height,
38
+ errors?.length && 'border-danger-300',
39
+ disabled && 'cursor-not-allowed opacity-50',
41
40
  'border-default-300 focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2',
42
41
  className
43
42
  )
@@ -45,12 +44,9 @@
45
44
 
46
45
  const inputClass = $derived(
47
46
  cn(
48
- 'w-full bg-transparent outline-none disabled:cursor-not-allowed px-3 placeholder:text-default-400',
49
- {
50
- 'text-sm': size === Size.SM,
51
- 'text-base': size === Size.MD,
52
- 'text-lg': size === Size.LG
53
- }
47
+ 'w-full bg-transparent outline-none disabled:cursor-not-allowed placeholder:text-default-400',
48
+ tokens.padX,
49
+ tokens.text
54
50
  )
55
51
  );
56
52
 
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { buildTestId } from '../helper/testid.js';
4
4
  import { Color, Size } from '../variants.js';
5
+ import { formSizeTokens } from './form-size.js';
5
6
  import type { RadioGroupProps } from '../index.js';
6
7
 
7
8
  let {
@@ -21,7 +22,10 @@
21
22
  }: RadioGroupProps = $props();
22
23
 
23
24
  const hasErrors = $derived(errors.length > 0);
25
+ const tokens = $derived(formSizeTokens[size]);
24
26
 
27
+ // Radio circle scales with the form ladder — `size-3` at xs through
28
+ // `size-6` at xl. 2xl aliases xl.
25
29
  const dotSize = $derived(
26
30
  {
27
31
  [Size.XS]: 'size-3',
@@ -29,7 +33,7 @@
29
33
  [Size.MD]: 'size-4',
30
34
  [Size.LG]: 'size-5',
31
35
  [Size.XL]: 'size-6',
32
- [Size.XXL]: 'size-7'
36
+ [Size.XXL]: 'size-6'
33
37
  }[size]
34
38
  );
35
39
 
@@ -104,7 +108,7 @@
104
108
  {/if}
105
109
  </span>
106
110
  <span class="flex flex-col">
107
- <span class="text-default-800 text-sm">{option.label}</span>
111
+ <span class={cn('text-default-800', tokens.text)}>{option.label}</span>
108
112
  {#if option.description}
109
113
  <span class="text-default-500 text-xs">{option.description}</span>
110
114
  {/if}
@@ -2,6 +2,7 @@
2
2
  import Badge from '../elements/badge/Badge.svelte';
3
3
  import { Size } from '../variants.js';
4
4
  import { cn } from '../helper/cls.js';
5
+ import { formSizeTokens } from './form-size.js';
5
6
  import { fade } from 'svelte/transition';
6
7
  import { flip } from 'svelte/animate';
7
8
  import { quintOut } from 'svelte/easing';
@@ -34,6 +35,25 @@
34
35
  showSuggestions ? suggestions.filter(isUnselected).filter(matchesInput).slice(0, 5) : []
35
36
  );
36
37
 
38
+ const tokens = $derived(formSizeTokens[size]);
39
+
40
+ // Chip size shifted one tier down from the Tags container so chip
41
+ // text matches the container text (e.g. Tags `md` uses `text-xs`, so
42
+ // Badge `sm` — which is also `text-xs` — lines up, instead of Badge
43
+ // `md` which jumps to `text-sm`).
44
+ const chipSize = $derived(
45
+ (
46
+ {
47
+ [Size.XS]: Size.XS,
48
+ [Size.SM]: Size.XS,
49
+ [Size.MD]: Size.SM,
50
+ [Size.LG]: Size.MD,
51
+ [Size.XL]: Size.LG,
52
+ [Size.XXL]: Size.LG
53
+ } as const
54
+ )[size]
55
+ );
56
+
37
57
  function handleKeydown(event: KeyboardEvent) {
38
58
  if (event.key === 'Enter') {
39
59
  event.preventDefault();
@@ -108,12 +128,14 @@
108
128
 
109
129
  const containerClass = $derived(
110
130
  cn(
111
- 'relative flex flex-wrap gap-2 rounded-lg border bg-white shadow-xs px-3 py-2',
131
+ 'relative flex flex-wrap gap-2 border bg-white',
132
+ tokens.radius,
133
+ tokens.shadow,
134
+ tokens.padX,
135
+ tokens.padY,
112
136
  'border-default-300 focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2',
113
- {
114
- 'border-danger-300 focus-within:border-danger-500 focus-within:ring-danger-500':
115
- errors?.length
116
- },
137
+ errors?.length &&
138
+ 'border-danger-300 focus-within:border-danger-500 focus-within:ring-danger-500',
117
139
  className
118
140
  )
119
141
  );
@@ -143,7 +165,7 @@
143
165
  transition:fade={{ duration: 250, easing: quintOut }}
144
166
  animate:flip={{ duration: 300, easing: quintOut }}
145
167
  >
146
- <Badge {size} color="info" onclose={() => handleTagRemoval(tag)} class="shadow-xs">
168
+ <Badge size={chipSize} color="info" onclose={() => handleTagRemoval(tag)} class="shadow-xs">
147
169
  {tag}
148
170
  </Badge>
149
171
  </div>
@@ -154,11 +176,10 @@
154
176
  {name}
155
177
  id={name}
156
178
  {placeholder}
157
- class={cn('placeholder:text-default-400 min-w-[120px] flex-1 bg-transparent outline-none', {
158
- 'text-sm': size === Size.SM,
159
- 'text-base': size === Size.MD,
160
- 'text-lg': size === Size.LG
161
- })}
179
+ class={cn(
180
+ 'placeholder:text-default-400 min-w-[120px] flex-1 bg-transparent outline-none',
181
+ tokens.text
182
+ )}
162
183
  type="text"
163
184
  autocomplete="off"
164
185
  onkeydown={handleKeydown}
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { buildTestId } from '../helper/testid.js';
4
4
  import { Size } from '../variants.js';
5
+ import { formSizeTokens } from './form-size.js';
5
6
  import type { TextareaProps } from '../index.js';
6
7
 
7
8
  let {
@@ -27,25 +28,19 @@
27
28
 
28
29
  let el = $state<HTMLTextAreaElement | undefined>();
29
30
 
30
- const sizeClass = $derived(
31
- {
32
- [Size.XS]: 'text-xs',
33
- [Size.SM]: 'text-sm',
34
- [Size.MD]: 'text-sm',
35
- [Size.LG]: 'text-base',
36
- [Size.XL]: 'text-lg',
37
- [Size.XXL]: 'text-lg'
38
- }[size]
39
- );
40
-
31
+ const tokens = $derived(formSizeTokens[size]);
41
32
  const hasErrors = $derived(errors.length > 0);
42
33
 
43
34
  const textareaClasses = $derived(
44
35
  cn(
45
- 'w-full rounded-lg border bg-white px-3 py-2 shadow-xs transition-colors',
36
+ 'w-full border bg-white transition-colors',
46
37
  'placeholder:text-default-400',
47
38
  'focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2',
48
- sizeClass,
39
+ tokens.radius,
40
+ tokens.shadow,
41
+ tokens.padX,
42
+ tokens.padY,
43
+ tokens.text,
49
44
  hasErrors
50
45
  ? 'border-danger-300 focus-within:border-danger-500 focus-within:ring-danger-500'
51
46
  : 'border-default-300 focus-within:border-primary-500 focus-within:ring-primary-500',
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../helper/cls.js';
3
3
  import { Color, Size } from '../variants.js';
4
4
  import { buildTestId } from '../helper/testid.js';
5
+ import { formSizeTokens } from './form-size.js';
5
6
  import type { ToggleProps, VariantColors, VariantSizes } from '../index.js';
6
7
 
7
8
  let {
@@ -35,13 +36,19 @@
35
36
  )[color]
36
37
  );
37
38
 
39
+ const tokens = $derived(formSizeTokens[size]);
40
+
41
+ // Track + thumb + on-state offset ladder. Chosen to roughly match the
42
+ // height of an Input at the same `size` (20/24/28/36/44px) so a row
43
+ // with a Toggle and an Input reads evenly. 2xl aliases xl — form
44
+ // controls cap at xl.
38
45
  const toggleSize = $derived(
39
46
  (
40
47
  {
41
- [Size.XS]: 'w-8 h-4',
42
- [Size.SM]: 'w-8 h-4',
43
- [Size.MD]: 'w-10 h-5',
44
- [Size.LG]: 'w-12 h-6',
48
+ [Size.XS]: 'w-6 h-3',
49
+ [Size.SM]: 'w-7 h-3.5',
50
+ [Size.MD]: 'w-8 h-4',
51
+ [Size.LG]: 'w-10 h-5',
45
52
  [Size.XL]: 'w-12 h-6',
46
53
  [Size.XXL]: 'w-12 h-6'
47
54
  } satisfies Record<VariantSizes, string>
@@ -51,10 +58,10 @@
51
58
  const thumbSize = $derived(
52
59
  (
53
60
  {
54
- [Size.XS]: 'h-3 w-3',
55
- [Size.SM]: 'h-3 w-3',
56
- [Size.MD]: 'h-4 w-4',
57
- [Size.LG]: 'h-5 w-5',
61
+ [Size.XS]: 'h-2 w-2',
62
+ [Size.SM]: 'h-2.5 w-2.5',
63
+ [Size.MD]: 'h-3 w-3',
64
+ [Size.LG]: 'h-4 w-4',
58
65
  [Size.XL]: 'h-5 w-5',
59
66
  [Size.XXL]: 'h-5 w-5'
60
67
  } satisfies Record<VariantSizes, string>
@@ -64,10 +71,10 @@
64
71
  const thumbPosition = $derived(
65
72
  (
66
73
  {
67
- [Size.XS]: value ? 'translate-x-4' : 'translate-x-0.5',
68
- [Size.SM]: value ? 'translate-x-4' : 'translate-x-0.5',
69
- [Size.MD]: value ? 'translate-x-5' : 'translate-x-0.5',
70
- [Size.LG]: value ? 'translate-x-6' : 'translate-x-0.5',
74
+ [Size.XS]: value ? 'translate-x-3' : 'translate-x-0.5',
75
+ [Size.SM]: value ? 'translate-x-3.5' : 'translate-x-0.5',
76
+ [Size.MD]: value ? 'translate-x-4' : 'translate-x-0.5',
77
+ [Size.LG]: value ? 'translate-x-5' : 'translate-x-0.5',
71
78
  [Size.XL]: value ? 'translate-x-6' : 'translate-x-0.5',
72
79
  [Size.XXL]: value ? 'translate-x-6' : 'translate-x-0.5'
73
80
  } satisfies Record<VariantSizes, string>
@@ -112,11 +119,12 @@
112
119
  );
113
120
 
114
121
  const labelClasses = $derived(
115
- cn('text-sm font-medium', {
116
- 'text-default-700': !errors.length,
117
- 'text-danger-600': errors.length,
118
- 'opacity-50': disabled
119
- })
122
+ cn(
123
+ 'font-medium',
124
+ tokens.text,
125
+ errors.length ? 'text-danger-600' : 'text-default-700',
126
+ disabled && 'opacity-50'
127
+ )
120
128
  );
121
129
 
122
130
  function handleKeyDown(event: KeyboardEvent) {
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../../helper/cls.js';
3
3
  import { buildTestId } from '../../helper/testid.js';
4
+ import { Size } from '../../variants.js';
5
+ import type { VariantSizes } from '../../index.js';
4
6
  import type { CalendarProps } from './calendar-types.js';
5
7
 
6
8
  let {
@@ -14,11 +16,91 @@
14
16
  weekStartsOn = 1,
15
17
  hideHeader = false,
16
18
  disabled = false,
19
+ size = Size.MD,
17
20
  class: className = '',
18
21
  onselect,
19
22
  testId
20
23
  }: CalendarProps = $props();
21
24
 
25
+ // Calendar dimensions don't map cleanly onto form-size tokens — a
26
+ // date grid's "size" means cell + panel width, not input height. So
27
+ // we maintain a dedicated ladder here, tuned so a Calendar at the
28
+ // same `size` as its enclosing DatePicker feels proportional.
29
+ type CalendarDensity = {
30
+ panel: string;
31
+ padding: string;
32
+ cell: string;
33
+ navBtn: string;
34
+ navIcon: string;
35
+ monthText: string;
36
+ dayHeaderText: string;
37
+ cellText: string;
38
+ };
39
+ const calendarSize: Record<VariantSizes, CalendarDensity> = {
40
+ [Size.XS]: {
41
+ panel: 'w-48',
42
+ padding: 'p-2',
43
+ cell: 'size-6',
44
+ navBtn: 'size-5',
45
+ navIcon: 'size-3',
46
+ monthText: 'text-xs',
47
+ dayHeaderText: 'text-[9px]',
48
+ cellText: 'text-[10px]'
49
+ },
50
+ [Size.SM]: {
51
+ panel: 'w-56',
52
+ padding: 'p-2.5',
53
+ cell: 'size-7',
54
+ navBtn: 'size-6',
55
+ navIcon: 'size-3.5',
56
+ monthText: 'text-xs',
57
+ dayHeaderText: 'text-[10px]',
58
+ cellText: 'text-xs'
59
+ },
60
+ [Size.MD]: {
61
+ panel: 'w-64',
62
+ padding: 'p-3',
63
+ cell: 'size-8',
64
+ navBtn: 'size-7',
65
+ navIcon: 'size-4',
66
+ monthText: 'text-sm',
67
+ dayHeaderText: 'text-[10px]',
68
+ cellText: 'text-xs'
69
+ },
70
+ [Size.LG]: {
71
+ panel: 'w-72',
72
+ padding: 'p-3.5',
73
+ cell: 'size-9',
74
+ navBtn: 'size-8',
75
+ navIcon: 'size-4',
76
+ monthText: 'text-base',
77
+ dayHeaderText: 'text-xs',
78
+ cellText: 'text-sm'
79
+ },
80
+ [Size.XL]: {
81
+ panel: 'w-80',
82
+ padding: 'p-4',
83
+ cell: 'size-10',
84
+ navBtn: 'size-9',
85
+ navIcon: 'size-5',
86
+ monthText: 'text-lg',
87
+ dayHeaderText: 'text-xs',
88
+ cellText: 'text-sm'
89
+ },
90
+ // Form controls cap at xl — see `form-size.ts`.
91
+ [Size.XXL]: {
92
+ panel: 'w-80',
93
+ padding: 'p-4',
94
+ cell: 'size-10',
95
+ navBtn: 'size-9',
96
+ navIcon: 'size-5',
97
+ monthText: 'text-lg',
98
+ dayHeaderText: 'text-xs',
99
+ cellText: 'text-sm'
100
+ }
101
+ };
102
+ const density = $derived(calendarSize[size]);
103
+
22
104
  const anchor = $derived(initialMonth ?? value ?? valueStart ?? new Date());
23
105
 
24
106
  let viewYear = $state(anchor.getFullYear());
@@ -146,7 +228,9 @@
146
228
 
147
229
  <div
148
230
  class={cn(
149
- 'border-default-200 inline-block w-64 rounded-lg border bg-white p-3 shadow-xs select-none',
231
+ 'border-default-200 inline-block rounded-lg border bg-white shadow-xs select-none',
232
+ density.panel,
233
+ density.padding,
150
234
  className
151
235
  )}
152
236
  data-testid={buildTestId('calendar', undefined, testId)}
@@ -156,11 +240,14 @@
156
240
  <button
157
241
  type="button"
158
242
  onclick={prevMonth}
159
- class="text-default-500 hover:bg-default-100 hover:text-default-800 flex size-7 cursor-pointer items-center justify-center rounded"
243
+ class={cn(
244
+ 'text-default-500 hover:bg-default-100 hover:text-default-800 flex cursor-pointer items-center justify-center rounded',
245
+ density.navBtn
246
+ )}
160
247
  aria-label="Previous month"
161
248
  {disabled}
162
249
  >
163
- <svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
250
+ <svg class={density.navIcon} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
164
251
  <path
165
252
  fill-rule="evenodd"
166
253
  d="M12.78 5.22a.75.75 0 0 1 0 1.06L9.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0z"
@@ -168,15 +255,18 @@
168
255
  />
169
256
  </svg>
170
257
  </button>
171
- <span class="text-default-800 text-sm font-semibold">{monthLabel}</span>
258
+ <span class={cn('text-default-800 font-semibold', density.monthText)}>{monthLabel}</span>
172
259
  <button
173
260
  type="button"
174
261
  onclick={nextMonth}
175
- class="text-default-500 hover:bg-default-100 hover:text-default-800 flex size-7 cursor-pointer items-center justify-center rounded"
262
+ class={cn(
263
+ 'text-default-500 hover:bg-default-100 hover:text-default-800 flex cursor-pointer items-center justify-center rounded',
264
+ density.navBtn
265
+ )}
176
266
  aria-label="Next month"
177
267
  {disabled}
178
268
  >
179
- <svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
269
+ <svg class={density.navIcon} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
180
270
  <path
181
271
  fill-rule="evenodd"
182
272
  d="M7.22 14.78a.75.75 0 0 1 0-1.06L10.94 10 7.22 6.28a.75.75 0 0 1 1.06-1.06l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0z"
@@ -187,7 +277,12 @@
187
277
  </div>
188
278
  {/if}
189
279
 
190
- <div class="text-default-400 mb-1 grid grid-cols-7 gap-0.5 text-center text-[10px] font-medium">
280
+ <div
281
+ class={cn(
282
+ 'text-default-400 mb-1 grid grid-cols-7 gap-0.5 text-center font-medium',
283
+ density.dayHeaderText
284
+ )}
285
+ >
191
286
  {#each dayHeaders() as d (d)}
192
287
  <div>{d}</div>
193
288
  {/each}
@@ -202,7 +297,9 @@
202
297
  aria-pressed={cell.isSelected}
203
298
  aria-label={cell.date.toLocaleDateString()}
204
299
  class={cn(
205
- 'relative flex size-8 items-center justify-center rounded text-xs transition-colors',
300
+ 'relative flex items-center justify-center rounded transition-colors',
301
+ density.cell,
302
+ density.cellText,
206
303
  !cell.inMonth && 'text-default-300',
207
304
  cell.inMonth && !cell.disabled && 'text-default-700 hover:bg-default-100 cursor-pointer',
208
305
  cell.disabled && 'text-default-200 cursor-not-allowed',
@@ -1,4 +1,5 @@
1
1
  import type { ClassValue } from 'tailwind-variants';
2
+ import type { VariantSizes } from '../../index.js';
2
3
  /**
3
4
  * Calendar selection mode.
4
5
  * - `'single'`: one date. Bind to `value`.
@@ -31,6 +32,13 @@ export type CalendarProps = {
31
32
  hideHeader?: boolean;
32
33
  /** Disable all interaction. */
33
34
  disabled?: boolean;
35
+ /**
36
+ * Density preset. Scales day cells, headers, and overall panel width
37
+ * to match the tight form-size ladder so a `size="sm"` Calendar fits
38
+ * inside a `size="sm"` DatePicker / DateRange popover without feeling
39
+ * oversized. `2xl` aliases `xl`. @default 'md'
40
+ */
41
+ size?: VariantSizes;
34
42
  /** Wrapper class. */
35
43
  class?: ClassValue;
36
44
  /**
@@ -2,6 +2,7 @@
2
2
  import { cn } from '../../helper/cls.js';
3
3
  import { buildTestId } from '../../helper/testid.js';
4
4
  import { Size } from '../../variants.js';
5
+ import { formSizeTokens } from '../form-size.js';
5
6
  import Popover from '../../elements/popover/Popover.svelte';
6
7
  import Calendar from '../calendar/Calendar.svelte';
7
8
  import type { DatePickerProps } from './date-picker-types.js';
@@ -38,16 +39,7 @@
38
39
  const display = $derived(value ? formatDate(value) : '');
39
40
  const hasErrors = $derived(errors.length > 0);
40
41
 
41
- const sizeClass = $derived(
42
- {
43
- [Size.XS]: 'h-7 text-xs',
44
- [Size.SM]: 'h-8 text-sm',
45
- [Size.MD]: 'h-10 text-sm',
46
- [Size.LG]: 'h-12 text-base',
47
- [Size.XL]: 'h-14 text-lg',
48
- [Size.XXL]: 'h-16 text-lg'
49
- }[size]
50
- );
42
+ const tokens = $derived(formSizeTokens[size]);
51
43
 
52
44
  function clear(e: MouseEvent) {
53
45
  e.stopPropagation();
@@ -83,9 +75,14 @@
83
75
  aria-expanded={open}
84
76
  aria-invalid={hasErrors}
85
77
  class={cn(
86
- 'flex w-full items-center justify-between gap-2 rounded-lg border bg-white px-3 transition-colors',
78
+ 'flex w-full items-center justify-between border bg-white transition-colors',
87
79
  'focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none',
88
- sizeClass,
80
+ tokens.height,
81
+ tokens.padX,
82
+ tokens.text,
83
+ tokens.gap,
84
+ tokens.radius,
85
+ tokens.shadow,
89
86
  hasErrors
90
87
  ? 'border-danger-300 focus-within:border-danger-500 focus-within:ring-danger-500'
91
88
  : 'border-default-300 focus-within:border-primary-500 focus-within:ring-primary-500',
@@ -115,7 +112,7 @@
115
112
  </button>
116
113
  {:else}
117
114
  <svg
118
- class="text-default-400 size-4"
115
+ class={cn('text-default-400', tokens.iconSize)}
119
116
  viewBox="0 0 20 20"
120
117
  fill="currentColor"
121
118
  aria-hidden="true"
@@ -130,7 +127,7 @@
130
127
  </button>
131
128
 
132
129
  {#snippet content()}
133
- <Calendar {value} {minDate} {maxDate} onselect={(d) => handleSelect(d as Date)} />
130
+ <Calendar {value} {minDate} {maxDate} {size} onselect={(d) => handleSelect(d as Date)} />
134
131
  {/snippet}
135
132
  </Popover>
136
133
 
@@ -0,0 +1,37 @@
1
+ import type { VariantSizes } from '../index.js';
2
+ /**
3
+ * Canonical size tokens used by every form control — `Input`, `Textarea`,
4
+ * `NumberInput`, `Tags`, `Checkbox`, and the select-based controls.
5
+ *
6
+ * The goal is that a form using `size="sm"` across mixed controls looks
7
+ * vertically aligned and typographically coordinated. Before this map,
8
+ * each component had its own inline size map that disagreed with the
9
+ * others (e.g. `Input` only supported SM/MD/LG while `Textarea` supported
10
+ * all six, and `MD` meant `text-base` in `Input` but `text-sm` in
11
+ * `Textarea`).
12
+ *
13
+ * Multi-line controls (Textarea) read only `padX` / `padY` / `text` —
14
+ * height is content-driven. Single-line controls also read `height`.
15
+ */
16
+ export type FormSizeTokens = {
17
+ /** Control height for single-line inputs (e.g. `h-10`). */
18
+ height: string;
19
+ /** Horizontal padding token (e.g. `px-3`). */
20
+ padX: string;
21
+ /** Vertical padding, used by multi-line controls and chip rows. */
22
+ padY: string;
23
+ /** Font size token (e.g. `text-base`). */
24
+ text: string;
25
+ /** Inline gap between leading icon and text (e.g. `gap-2`). */
26
+ gap: string;
27
+ /** Inline icon dimension (e.g. `size-4`). */
28
+ iconSize: string;
29
+ /** Rounded corner radius that scales with the control (e.g. `rounded-lg`). */
30
+ radius: string;
31
+ /**
32
+ * Drop shadow. `shadow-none` at the smallest sizes so `xs` / inline
33
+ * table cells don't stand out against their container.
34
+ */
35
+ shadow: string;
36
+ };
37
+ export declare const formSizeTokens: Record<VariantSizes, FormSizeTokens>;
@@ -0,0 +1,67 @@
1
+ import { Size } from '../variants.js';
2
+ // Tight ladder by design — we prefer compact, focused form controls
3
+ // over bulky ones. `md` (the default) sits at `h-7` / `text-xs` so a
4
+ // stock form reads dense; consumers who want roomier controls opt up to
5
+ // `lg` / `xl`. `xs` is unmistakably inline-text-in-a-table-cell — 20px
6
+ // tall, no shadow, barely-there rounded corners.
7
+ //
8
+ // `2xl` (`Size.XXL`) intentionally aliases `xl` for form controls: form
9
+ // fields don't need a sixth, jumbo tier — anything larger reads as a
10
+ // display element rather than an input. We still accept the token (the
11
+ // `Size` enum exposes it library-wide) and quietly fall back to `xl`
12
+ // rather than requiring every component to exclude it from its size type.
13
+ const xl = {
14
+ height: 'h-11',
15
+ padX: 'px-3.5',
16
+ padY: 'py-2.5',
17
+ text: 'text-base',
18
+ gap: 'gap-2.5',
19
+ iconSize: 'size-5',
20
+ radius: 'rounded-lg',
21
+ shadow: 'shadow-xs'
22
+ };
23
+ export const formSizeTokens = {
24
+ [Size.XS]: {
25
+ height: 'h-5',
26
+ padX: 'px-1',
27
+ padY: 'py-0',
28
+ text: 'text-xs',
29
+ gap: 'gap-1',
30
+ iconSize: 'size-3',
31
+ radius: 'rounded-sm',
32
+ shadow: 'shadow-none'
33
+ },
34
+ [Size.SM]: {
35
+ height: 'h-6',
36
+ padX: 'px-1.5',
37
+ padY: 'py-0.5',
38
+ text: 'text-xs',
39
+ gap: 'gap-1',
40
+ iconSize: 'size-3',
41
+ radius: 'rounded',
42
+ shadow: 'shadow-xs'
43
+ },
44
+ [Size.MD]: {
45
+ height: 'h-7',
46
+ padX: 'px-2',
47
+ padY: 'py-1',
48
+ text: 'text-xs',
49
+ gap: 'gap-1.5',
50
+ iconSize: 'size-3.5',
51
+ radius: 'rounded-md',
52
+ shadow: 'shadow-xs'
53
+ },
54
+ [Size.LG]: {
55
+ height: 'h-9',
56
+ padX: 'px-2.5',
57
+ padY: 'py-1.5',
58
+ text: 'text-sm',
59
+ gap: 'gap-2',
60
+ iconSize: 'size-4',
61
+ radius: 'rounded-md',
62
+ shadow: 'shadow-xs'
63
+ },
64
+ [Size.XL]: xl,
65
+ // Form controls cap at xl visually — see comment above.
66
+ [Size.XXL]: xl
67
+ };
@@ -291,6 +291,11 @@ export interface DateRangeProps {
291
291
  */
292
292
  format?: string;
293
293
  errors?: string[];
294
+ /**
295
+ * Trigger control size — shares the form-size ladder with `Input`,
296
+ * `Select`, etc. so a row of mixed controls lines up. @default 'md'
297
+ */
298
+ size?: VariantSizes;
294
299
  /**
295
300
  * Fires when the user completes a selection. Both dates may be
296
301
  * undefined (cleared).
@@ -1,6 +1,7 @@
1
1
  import { tv } from 'tailwind-variants';
2
2
  import { cn } from '../helper/cls.js';
3
- import { Color, Size } from '../variants.js';
3
+ import { Color } from '../variants.js';
4
+ import { formSizeTokens } from './form-size.js';
4
5
  export const segmentedTrack = tv({
5
6
  // `max-w-full overflow-x-auto` + hidden scrollbar lets a long segment
6
7
  // row swipe on narrow viewports instead of bleeding past the parent.
@@ -36,18 +37,18 @@ const selectedByColor = {
36
37
  [Color.WARNING]: 'bg-warning-600 text-white shadow-sm',
37
38
  [Color.DANGER]: 'bg-danger-600 text-white shadow-sm'
38
39
  };
39
- const segmentSize = {
40
- [Size.XS]: 'gap-1 px-2 py-1 text-xs',
41
- [Size.SM]: 'gap-1.5 px-3 py-1.5 text-xs',
42
- [Size.MD]: 'gap-1.5 px-3 py-1.5 text-sm',
43
- [Size.LG]: 'gap-2 px-4 py-2 text-base',
44
- [Size.XL]: 'gap-2 px-4 py-2.5 text-lg',
45
- [Size.XXL]: 'gap-2 px-5 py-3 text-xl'
46
- };
40
+ // Pull padding/text/gap from the shared form-size tokens so a
41
+ // SegmentedControl at `size="md"` coordinates with Input / Select /
42
+ // DateRange / etc. at the same tier. Segments don't use `height` —
43
+ // their height falls out of text + padding.
44
+ function segmentSize(size) {
45
+ const t = formSizeTokens[size];
46
+ return `${t.gap} ${t.padX} ${t.padY} ${t.text}`;
47
+ }
47
48
  export function segmentClasses(args) {
48
49
  const { selected, disabled, appearance, color, size } = args;
49
50
  if (appearance === 'pills') {
50
- const pillBase = cn('flex cursor-pointer items-center justify-center rounded-full font-medium transition-colors duration-150 shrink-0 whitespace-nowrap', 'focus-visible:ring-primary-500 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-offset-2', segmentSize[size], disabled && 'cursor-not-allowed opacity-50');
51
+ const pillBase = cn('flex cursor-pointer items-center justify-center rounded-full font-medium transition-colors duration-150 shrink-0 whitespace-nowrap', 'focus-visible:ring-primary-500 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-offset-2', segmentSize(size), disabled && 'cursor-not-allowed opacity-50');
51
52
  if (disabled)
52
53
  return cn(pillBase, 'text-default-400');
53
54
  if (selected)
@@ -57,7 +58,7 @@ export function segmentClasses(args) {
57
58
  const base = cn('flex cursor-pointer items-center justify-center font-medium transition-colors duration-150 shrink-0 whitespace-nowrap', 'focus-visible:ring-primary-500 focus-visible:ring-2 focus-visible:outline-none', appearance === 'inverted' ? 'focus-visible:ring-offset-0' : 'focus-visible:ring-offset-2',
58
59
  // Inverted track has p-0.5 padding around segments — give them inner radius
59
60
  // so selected segments don't render as square boxes (matches surface look).
60
- appearance === 'inverted' && 'rounded-md', segmentSize[size], disabled && 'cursor-not-allowed opacity-50');
61
+ appearance === 'inverted' && 'rounded-md', segmentSize(size), disabled && 'cursor-not-allowed opacity-50');
61
62
  if (disabled) {
62
63
  return cn(base, 'text-default-400');
63
64
  }
@@ -1,62 +1,69 @@
1
1
  import { tv } from 'tailwind-variants';
2
2
  import { Size } from '../variants.js';
3
+ import { formSizeTokens } from './form-size.js';
4
+ // Track/thumb dimensions are Slider-specific — a "size" for a slider
5
+ // means the physical rail, not an input height. We keep these tuned per
6
+ // tier but pull `text` classes from the shared form-size tokens so a
7
+ // Slider's label reads the same size as an adjacent Input's text.
8
+ const textFor = (size) => formSizeTokens[size].text;
3
9
  export const slider = tv({
4
10
  slots: {
5
11
  base: 'relative w-full',
6
- track: 'absolute h-2 w-full rounded-full bg-default-200 cursor-pointer',
12
+ track: 'absolute w-full rounded-full bg-default-200 cursor-pointer',
7
13
  range: 'absolute h-full rounded-full bg-primary-500',
8
14
  thumb: [
9
15
  'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white border-2 border-primary-500',
10
16
  'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
11
17
  'hover:scale-110 transition-transform cursor-pointer'
12
18
  ],
13
- mark: 'absolute text-sm text-default-500 -translate-x-1/2',
14
- label: 'mb-2 block text-sm font-medium text-default-700',
15
- value: 'mt-1 text-sm text-default-500'
19
+ mark: 'absolute text-default-500 -translate-x-1/2',
20
+ label: 'mb-2 block font-medium text-default-700',
21
+ value: 'mt-1 text-default-500'
16
22
  },
17
23
  variants: {
18
24
  size: {
19
25
  [Size.XS]: {
20
- track: 'h-1',
21
- thumb: 'w-3 h-3',
22
- mark: 'text-xs top-4',
23
- label: 'text-xs',
24
- value: 'text-xs'
26
+ track: 'h-0.5',
27
+ thumb: 'w-2.5 h-2.5',
28
+ mark: `${textFor(Size.XS)} top-3`,
29
+ label: textFor(Size.XS),
30
+ value: textFor(Size.XS)
25
31
  },
26
32
  [Size.SM]: {
27
33
  track: 'h-1',
28
34
  thumb: 'w-3 h-3',
29
- mark: 'text-xs top-4',
30
- label: 'text-xs',
31
- value: 'text-xs'
35
+ mark: `${textFor(Size.SM)} top-4`,
36
+ label: textFor(Size.SM),
37
+ value: textFor(Size.SM)
32
38
  },
33
39
  [Size.MD]: {
34
- track: 'h-2',
35
- thumb: 'w-4 h-4',
36
- mark: 'text-sm top-6',
37
- label: 'text-sm',
38
- value: 'text-sm'
40
+ track: 'h-1.5',
41
+ thumb: 'w-3.5 h-3.5',
42
+ mark: `${textFor(Size.MD)} top-5`,
43
+ label: textFor(Size.MD),
44
+ value: textFor(Size.MD)
39
45
  },
40
46
  [Size.LG]: {
41
- track: 'h-3',
42
- thumb: 'w-5 h-5',
43
- mark: 'text-base top-7',
44
- label: 'text-base',
45
- value: 'text-base'
47
+ track: 'h-2',
48
+ thumb: 'w-4 h-4',
49
+ mark: `${textFor(Size.LG)} top-6`,
50
+ label: textFor(Size.LG),
51
+ value: textFor(Size.LG)
46
52
  },
47
53
  [Size.XL]: {
48
54
  track: 'h-3',
49
55
  thumb: 'w-5 h-5',
50
- mark: 'text-base top-7',
51
- label: 'text-base',
52
- value: 'text-base'
56
+ mark: `${textFor(Size.XL)} top-7`,
57
+ label: textFor(Size.XL),
58
+ value: textFor(Size.XL)
53
59
  },
60
+ // Form controls cap at xl — see `form-size.ts`.
54
61
  [Size.XXL]: {
55
62
  track: 'h-3',
56
63
  thumb: 'w-5 h-5',
57
- mark: 'text-base top-7',
58
- label: 'text-base',
59
- value: 'text-base'
64
+ mark: `${textFor(Size.XXL)} top-7`,
65
+ label: textFor(Size.XXL),
66
+ value: textFor(Size.XXL)
60
67
  }
61
68
  },
62
69
  disabled: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makolabs/ripple",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Simple Svelte 5 powered component library ✨",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {