@sovereignfs/ui 0.21.2 → 0.23.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 (39) hide show
  1. package/dist/Checkbox.module.css +97 -0
  2. package/dist/Dialog.module.css +8 -4
  3. package/dist/DragHandleRow.module.css +51 -0
  4. package/dist/FormField.module.css +5 -0
  5. package/dist/Input.module.css +10 -0
  6. package/dist/Select.module.css +9 -0
  7. package/dist/Textarea.module.css +39 -0
  8. package/dist/Toast.module.css +3 -3
  9. package/dist/Toggle.module.css +1 -1
  10. package/dist/index.d.ts +58 -5
  11. package/dist/index.js +220 -98
  12. package/dist/tokens/semantic.css +6 -0
  13. package/package.json +1 -1
  14. package/src/components/Checkbox/Checkbox.module.css +97 -0
  15. package/src/components/Checkbox/Checkbox.tsx +68 -0
  16. package/src/components/Dialog/Dialog.module.css +8 -4
  17. package/src/components/DragHandleRow/DragHandleRow.module.css +51 -0
  18. package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
  19. package/src/components/FormField/FormField.module.css +5 -0
  20. package/src/components/FormField/FormField.tsx +36 -11
  21. package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
  22. package/src/components/Input/Input.module.css +10 -0
  23. package/src/components/Select/Select.module.css +9 -0
  24. package/src/components/Select/Select.tsx +10 -2
  25. package/src/components/Textarea/Textarea.module.css +39 -0
  26. package/src/components/Textarea/Textarea.tsx +15 -0
  27. package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
  28. package/src/components/Toast/Toast.module.css +3 -3
  29. package/src/components/Toggle/Toggle.module.css +1 -1
  30. package/src/index.ts +7 -1
  31. package/src/scroll-lock.ts +8 -2
  32. package/src/stories/Checkbox.stories.tsx +45 -0
  33. package/src/stories/DesignSystemOverview.stories.tsx +64 -6
  34. package/src/stories/DragHandleRow.stories.tsx +41 -0
  35. package/src/stories/FormField.stories.tsx +8 -8
  36. package/src/stories/MobilePatterns.stories.tsx +4 -4
  37. package/src/stories/Textarea.stories.tsx +51 -0
  38. package/src/stories/TokenGallery.stories.tsx +2 -0
  39. package/src/tokens/semantic.css +6 -0
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import type { InputHTMLAttributes } from 'react';
4
+ import styles from './Checkbox.module.css';
5
+
6
+ export interface CheckboxProps extends Omit<
7
+ InputHTMLAttributes<HTMLInputElement>,
8
+ 'type' | 'onChange'
9
+ > {
10
+ checked: boolean;
11
+ onChange: (checked: boolean) => void;
12
+ label: string;
13
+ /** Renders the label with a strike-through when checked. */
14
+ strikeThrough?: boolean;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export function Checkbox({
19
+ checked,
20
+ onChange,
21
+ label,
22
+ strikeThrough = false,
23
+ disabled,
24
+ className,
25
+ id,
26
+ ...rest
27
+ }: CheckboxProps) {
28
+ const inputId = id ?? `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
29
+
30
+ return (
31
+ <span className={[styles.root, className].filter(Boolean).join(' ')}>
32
+ <span className={[styles.box, checked ? styles.checked : ''].filter(Boolean).join(' ')}>
33
+ <input
34
+ {...rest}
35
+ id={inputId}
36
+ type="checkbox"
37
+ checked={checked}
38
+ disabled={disabled}
39
+ className={styles.input}
40
+ onChange={(e) => onChange(e.target.checked)}
41
+ />
42
+ {checked && (
43
+ <svg className={styles.tick} viewBox="0 0 10 8" fill="none" aria-hidden="true">
44
+ <path
45
+ d="M1 4l3 3 5-6"
46
+ stroke="currentColor"
47
+ strokeWidth="1.5"
48
+ strokeLinecap="round"
49
+ strokeLinejoin="round"
50
+ />
51
+ </svg>
52
+ )}
53
+ </span>
54
+ <label
55
+ htmlFor={inputId}
56
+ className={[
57
+ styles.label,
58
+ strikeThrough && checked ? styles.struck : '',
59
+ disabled ? styles.disabled : '',
60
+ ]
61
+ .filter(Boolean)
62
+ .join(' ')}
63
+ >
64
+ {label}
65
+ </label>
66
+ </span>
67
+ );
68
+ }
@@ -48,11 +48,15 @@
48
48
  .content {
49
49
  flex: 1 1 auto;
50
50
  overflow-y: auto;
51
+ overflow-x: hidden;
51
52
  padding: var(--sv-space-6);
52
- /* Prevent iOS scroll-chaining: when the dialog content reaches its top/bottom
53
- boundary, stop the gesture from propagating to the document (which would
54
- scroll the shell header and footer out of view in standalone PWA mode). */
55
- overscroll-behavior: contain;
53
+ /* Disable elastic overscroll within the dialog: `none` stops both scroll-
54
+ chaining to the parent and the iOS rubber-band bounce inside the panel.
55
+ `contain` only prevents chaining the panel itself still bounces, which
56
+ reveals white panel-background between the mobileBar and the sticky tab
57
+ strip on iOS PWA. A dialog is a bounded surface where bounce is a visual
58
+ bug, not a feature. */
59
+ overscroll-behavior: none;
56
60
  }
57
61
 
58
62
  /* Fixed sizes. `max-*: 100%` caps to the scrim's content box (viewport minus
@@ -0,0 +1,51 @@
1
+ .row {
2
+ display: flex;
3
+ align-items: flex-start;
4
+ gap: var(--sv-space-1);
5
+ position: relative;
6
+ }
7
+
8
+ .row:not(:hover) .handle,
9
+ .row:not(:focus-within) .handle {
10
+ opacity: 0;
11
+ }
12
+
13
+ .handle {
14
+ flex-shrink: 0;
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ width: 24px;
19
+ height: 24px;
20
+ margin-top: 2px;
21
+ border: none;
22
+ background: none;
23
+ padding: 0;
24
+ border-radius: var(--sv-radius-sm);
25
+ color: var(--sv-color-text-muted);
26
+ cursor: grab;
27
+ opacity: 1;
28
+ transition: opacity 0.1s ease;
29
+ }
30
+
31
+ .handle:focus-visible {
32
+ outline: 2px solid var(--sv-color-focus-ring);
33
+ outline-offset: 2px;
34
+ }
35
+
36
+ .handle:active {
37
+ cursor: grabbing;
38
+ }
39
+
40
+ .icon {
41
+ pointer-events: none;
42
+ }
43
+
44
+ .content {
45
+ flex: 1;
46
+ min-width: 0;
47
+ }
48
+
49
+ .dragging {
50
+ opacity: 0.5;
51
+ }
@@ -0,0 +1,61 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ import styles from './DragHandleRow.module.css';
3
+
4
+ export interface DragHandleRowProps extends HTMLAttributes<HTMLDivElement> {
5
+ children: ReactNode;
6
+ /** Pass drag-listener and drag-control props from dnd-kit here. */
7
+ handleProps?: HTMLAttributes<HTMLButtonElement>;
8
+ isDragging?: boolean;
9
+ }
10
+
11
+ /**
12
+ * A row wrapper that surfaces a drag handle on the left.
13
+ * Consumers attach dnd-kit's useSortable listeners to `handleProps`.
14
+ */
15
+ export function DragHandleRow({
16
+ children,
17
+ handleProps,
18
+ isDragging,
19
+ className,
20
+ ...rest
21
+ }: DragHandleRowProps) {
22
+ return (
23
+ <div
24
+ {...rest}
25
+ className={[styles.row, isDragging ? styles.dragging : '', className]
26
+ .filter(Boolean)
27
+ .join(' ')}
28
+ >
29
+ <button
30
+ type="button"
31
+ aria-label="Drag to reorder"
32
+ className={styles.handle}
33
+ tabIndex={-1}
34
+ {...handleProps}
35
+ >
36
+ <DragIcon />
37
+ </button>
38
+ <div className={styles.content}>{children}</div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ function DragIcon() {
44
+ return (
45
+ <svg
46
+ width="14"
47
+ height="14"
48
+ viewBox="0 0 14 14"
49
+ fill="none"
50
+ aria-hidden="true"
51
+ className={styles.icon}
52
+ >
53
+ {/* Two columns of three dots */}
54
+ {[3, 7, 11].map((cy) =>
55
+ [4, 10].map((cx) => (
56
+ <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.2} fill="currentColor" />
57
+ )),
58
+ )}
59
+ </svg>
60
+ );
61
+ }
@@ -2,6 +2,11 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  gap: var(--sv-space-1);
5
+ /* Every local .field/.fieldGroup class this component replaced set this
6
+ explicitly. Without it, a parent with align-items: flex-start (needed so
7
+ submit buttons don't stretch full-width) shrink-wraps this wrapper to its
8
+ content's intrinsic width instead of filling the available space. */
9
+ width: 100%;
5
10
  }
6
11
 
7
12
  .label {
@@ -2,32 +2,59 @@ import type { ReactNode } from 'react';
2
2
  import { useId } from 'react';
3
3
  import styles from './FormField.module.css';
4
4
 
5
+ /** Props to spread onto the field's actual form control. */
6
+ export interface FormFieldRenderProps {
7
+ id: string;
8
+ 'aria-describedby'?: string;
9
+ 'aria-invalid'?: boolean;
10
+ required?: boolean;
11
+ }
12
+
5
13
  export interface FormFieldProps {
6
14
  label: string;
7
15
  hint?: string;
8
16
  error?: string;
9
- htmlFor?: string;
17
+ /** Explicit control id. Auto-generated via `useId()` when omitted. */
18
+ id?: string;
10
19
  required?: boolean;
11
- children: ReactNode;
12
20
  className?: string;
21
+ /** Render prop — receives the props that must be spread onto the control
22
+ * so the label, hint, and error stay associated with it. */
23
+ children: (field: FormFieldRenderProps) => ReactNode;
13
24
  }
14
25
 
26
+ /**
27
+ * FormField — accessible label + hint/error wrapper for a single form control.
28
+ * Wires `htmlFor`/`id` and `aria-describedby`/`aria-invalid` onto the control
29
+ * itself (via the render-prop `field` object), not a surrounding element, so
30
+ * screen readers reliably announce the hint or error as part of the control.
31
+ */
15
32
  export function FormField({
16
33
  label,
17
34
  hint,
18
35
  error,
19
- htmlFor,
36
+ id,
20
37
  required = false,
21
- children,
22
38
  className,
39
+ children,
23
40
  }: FormFieldProps) {
24
- const hintId = useId();
25
- const errorId = useId();
26
- const describedBy = [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
41
+ const generatedId = useId();
42
+ const fieldId = id ?? generatedId;
43
+ const hintId = `${fieldId}-hint`;
44
+ const errorId = `${fieldId}-error`;
45
+ const describedBy =
46
+ [hint && !error && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
47
+
48
+ const field: FormFieldRenderProps = {
49
+ id: fieldId,
50
+ 'aria-describedby': describedBy,
51
+ 'aria-invalid': error ? true : undefined,
52
+ required: required || undefined,
53
+ };
27
54
 
28
55
  return (
29
56
  <div className={[styles.field, className].filter(Boolean).join(' ')}>
30
- <label className={styles.label} htmlFor={htmlFor}>
57
+ <label className={styles.label} htmlFor={fieldId}>
31
58
  {label}
32
59
  {required && (
33
60
  <span className={styles.required} aria-hidden="true">
@@ -35,9 +62,7 @@ export function FormField({
35
62
  </span>
36
63
  )}
37
64
  </label>
38
- {/* Clone the child to inject aria-describedby — or just render children
39
- and let the consumer wire htmlFor/id themselves */}
40
- <div aria-describedby={describedBy}>{children}</div>
65
+ {children(field)}
41
66
  {hint && !error && (
42
67
  <p id={hintId} className={styles.hint}>
43
68
  {hint}
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { FormField } from '../FormField';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('FormField', () => {
9
+ it('associates the label with the control via htmlFor/id', () => {
10
+ render(
11
+ <FormField label="Email" id="email">
12
+ {(field) => <input {...field} />}
13
+ </FormField>,
14
+ );
15
+ expect(screen.getByLabelText('Email')).toBeDefined();
16
+ });
17
+
18
+ it('generates an id when none is provided', () => {
19
+ render(<FormField label="Email">{(field) => <input {...field} />}</FormField>);
20
+ const input = screen.getByLabelText('Email') as HTMLInputElement;
21
+ expect(input.id).toBeTruthy();
22
+ });
23
+
24
+ it('wires the hint to the control via aria-describedby', () => {
25
+ render(
26
+ <FormField label="Username" hint="Letters and numbers only." id="username">
27
+ {(field) => <input {...field} />}
28
+ </FormField>,
29
+ );
30
+ const input = screen.getByLabelText('Username');
31
+ const hint = screen.getByText('Letters and numbers only.');
32
+ expect(input.getAttribute('aria-describedby')).toBe(hint.id);
33
+ });
34
+
35
+ it('wires the error to the control via aria-describedby and marks it invalid', () => {
36
+ render(
37
+ <FormField label="Password" error="Too short." id="password">
38
+ {(field) => <input {...field} />}
39
+ </FormField>,
40
+ );
41
+ const input = screen.getByLabelText('Password');
42
+ const error = screen.getByText('Too short.');
43
+ expect(input.getAttribute('aria-describedby')).toBe(error.id);
44
+ expect(input.getAttribute('aria-invalid')).toBe('true');
45
+ });
46
+
47
+ it('renders the error with role="alert"', () => {
48
+ render(
49
+ <FormField label="Password" error="Too short." id="password">
50
+ {(field) => <input {...field} />}
51
+ </FormField>,
52
+ );
53
+ expect(screen.getByRole('alert').textContent).toBe('Too short.');
54
+ });
55
+
56
+ it('hides the hint when an error is present', () => {
57
+ render(
58
+ <FormField label="Password" hint="At least 8 characters." error="Too short." id="password">
59
+ {(field) => <input {...field} />}
60
+ </FormField>,
61
+ );
62
+ expect(screen.queryByText('At least 8 characters.')).toBeNull();
63
+ });
64
+
65
+ it('shows a required indicator and sets required on the control', () => {
66
+ render(
67
+ <FormField label="Full name" required id="name">
68
+ {(field) => <input {...field} />}
69
+ </FormField>,
70
+ );
71
+ const input = screen.getByLabelText(/Full name/) as HTMLInputElement;
72
+ expect(input.required).toBe(true);
73
+ });
74
+
75
+ it('does not set aria-describedby when there is no hint or error', () => {
76
+ render(
77
+ <FormField label="Nickname" id="nickname">
78
+ {(field) => <input {...field} />}
79
+ </FormField>,
80
+ );
81
+ expect(screen.getByLabelText('Nickname').hasAttribute('aria-describedby')).toBe(false);
82
+ });
83
+ });
@@ -14,6 +14,16 @@
14
14
  box-shadow 0.15s ease;
15
15
  }
16
16
 
17
+ /* iOS Safari (tab and standalone PWA) auto-zooms the page whenever a focused
18
+ input's font-size is below 16px. The viewport intentionally allows pinch-zoom
19
+ (no maximum-scale — an accessibility requirement), so the only way to stop the
20
+ focus-zoom is to render the field at >=16px on touch devices. */
21
+ @media (pointer: coarse) {
22
+ .input {
23
+ font-size: var(--sv-font-size-md);
24
+ }
25
+ }
26
+
17
27
  .input::placeholder {
18
28
  color: var(--sv-color-text-muted);
19
29
  }
@@ -33,6 +33,15 @@
33
33
  border-radius: var(--sv-radius-sm);
34
34
  }
35
35
 
36
+ /* iOS Safari zooms the page when a <select> under 16px receives focus, exactly
37
+ as it does for text inputs. Lift the default size to >=16px on touch devices;
38
+ the compact `.sm` variant is for dense desktop tables and is left untouched. */
39
+ @media (pointer: coarse) {
40
+ .select {
41
+ font-size: var(--sv-font-size-md);
42
+ }
43
+ }
44
+
36
45
  .select:focus-visible {
37
46
  outline: none;
38
47
  border-color: var(--sv-color-accent);
@@ -12,13 +12,21 @@ export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'>
12
12
  * Uses the native picker on mobile — no custom dropdown, maximum a11y coverage.
13
13
  *
14
14
  * RSC-safe: presentational, no hooks, forwards all native select props.
15
+ *
16
+ * `className` sizes the outer box (the wrapper), not the `<select>` itself —
17
+ * the chevron is absolutely positioned against the wrapper, so a className
18
+ * that constrains only the `<select>` (e.g. `max-width`) shrinks the select
19
+ * while the wrapper (and thus the chevron) stays full width, leaving the
20
+ * chevron stranded past the visible box.
15
21
  */
16
22
  export function Select({ className, size = 'md', children, ...rest }: SelectProps) {
17
23
  return (
18
24
  <div
19
- className={[styles.wrapper, size === 'sm' ? styles.sm : undefined].filter(Boolean).join(' ')}
25
+ className={[styles.wrapper, size === 'sm' ? styles.sm : undefined, className]
26
+ .filter(Boolean)
27
+ .join(' ')}
20
28
  >
21
- <select className={[styles.select, className].filter(Boolean).join(' ')} {...rest}>
29
+ <select className={styles.select} {...rest}>
22
30
  {children}
23
31
  </select>
24
32
  <svg
@@ -0,0 +1,39 @@
1
+ .textarea {
2
+ display: block;
3
+ width: 100%;
4
+ padding: var(--sv-space-2) var(--sv-space-3);
5
+ font-family: var(--sv-font-family);
6
+ font-size: var(--sv-font-size-sm);
7
+ line-height: 1.5;
8
+ color: var(--sv-color-text-primary);
9
+ background-color: var(--sv-color-surface);
10
+ border: 1px solid var(--sv-color-border-strong);
11
+ border-radius: var(--sv-radius-md);
12
+ resize: vertical;
13
+ transition:
14
+ border-color 0.15s ease,
15
+ box-shadow 0.15s ease;
16
+ }
17
+
18
+ .textarea::placeholder {
19
+ color: var(--sv-color-text-muted);
20
+ }
21
+
22
+ .textarea:focus-visible {
23
+ outline: none;
24
+ border-color: var(--sv-color-accent);
25
+ box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
26
+ }
27
+
28
+ .textarea:read-only {
29
+ background-color: var(--sv-color-surface-sunken);
30
+ color: var(--sv-color-text-muted);
31
+ border-color: var(--sv-color-border);
32
+ cursor: default;
33
+ }
34
+
35
+ .textarea:disabled {
36
+ opacity: 0.5;
37
+ cursor: not-allowed;
38
+ resize: none;
39
+ }
@@ -0,0 +1,15 @@
1
+ import type { TextareaHTMLAttributes } from 'react';
2
+ import styles from './Textarea.module.css';
3
+
4
+ export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
5
+
6
+ /**
7
+ * Textarea — the primitive multi-line text field. Presentational and
8
+ * RSC-safe: it forwards all native textarea props to the underlying
9
+ * `<textarea>`. Styling references `--sv-*` tokens via CSS Modules; there
10
+ * are no hardcoded values.
11
+ */
12
+ export function Textarea({ className, rows = 4, ...rest }: TextareaProps) {
13
+ const classes = [styles.textarea, className].filter(Boolean).join(' ');
14
+ return <textarea rows={rows} className={classes} {...rest} />;
15
+ }
@@ -0,0 +1,29 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { Textarea } from '../Textarea';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('Textarea', () => {
9
+ it('renders and forwards native props', () => {
10
+ render(<Textarea placeholder="Bio" defaultValue="Hello" />);
11
+ const textarea = screen.getByPlaceholderText('Bio') as HTMLTextAreaElement;
12
+ expect(textarea.value).toBe('Hello');
13
+ });
14
+
15
+ it('defaults to 4 rows', () => {
16
+ render(<Textarea aria-label="field" />);
17
+ expect((screen.getByLabelText('field') as HTMLTextAreaElement).rows).toBe(4);
18
+ });
19
+
20
+ it('honours an explicit rows value', () => {
21
+ render(<Textarea aria-label="field" rows={8} />);
22
+ expect((screen.getByLabelText('field') as HTMLTextAreaElement).rows).toBe(8);
23
+ });
24
+
25
+ it('is disabled when the disabled prop is set', () => {
26
+ render(<Textarea aria-label="field" disabled />);
27
+ expect((screen.getByLabelText('field') as HTMLTextAreaElement).disabled).toBe(true);
28
+ });
29
+ });
@@ -80,7 +80,7 @@
80
80
 
81
81
  .message {
82
82
  font-size: var(--sv-font-size-sm);
83
- color: var(--sv-color-text-secondary);
83
+ color: var(--sv-color-text-muted);
84
84
  margin-top: var(--sv-space-1);
85
85
  line-height: 1.4;
86
86
  }
@@ -91,9 +91,9 @@
91
91
  border: none;
92
92
  padding: 0;
93
93
  cursor: pointer;
94
- color: var(--sv-color-text-tertiary);
94
+ color: var(--sv-color-text-subtle);
95
95
  line-height: 1;
96
- font-size: var(--sv-font-size-base);
96
+ font-size: var(--sv-font-size-md);
97
97
  }
98
98
 
99
99
  .close:hover {
@@ -40,7 +40,7 @@
40
40
  background: var(--sv-white);
41
41
  transition: transform 0.15s ease;
42
42
  left: 2px;
43
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
43
+ box-shadow: var(--sv-shadow-control);
44
44
  }
45
45
 
46
46
  .on .thumb {
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export { Button } from './components/Button/Button';
21
21
  export type { ButtonProps, ButtonVariant, ButtonSize } from './components/Button/Button';
22
22
  export { Input } from './components/Input/Input';
23
23
  export type { InputProps } from './components/Input/Input';
24
+ export { Textarea } from './components/Textarea/Textarea';
25
+ export type { TextareaProps } from './components/Textarea/Textarea';
24
26
  export { Dialog } from './components/Dialog/Dialog';
25
27
  export type { DialogProps, DialogSize } from './components/Dialog/Dialog';
26
28
  export { Drawer } from './components/Drawer/Drawer';
@@ -30,7 +32,7 @@ export type { ToastItem, ToastContextValue } from './components/Toast/Toast';
30
32
  export { Card } from './components/Card/Card';
31
33
  export type { CardProps } from './components/Card/Card';
32
34
  export { FormField } from './components/FormField/FormField';
33
- export type { FormFieldProps } from './components/FormField/FormField';
35
+ export type { FormFieldProps, FormFieldRenderProps } from './components/FormField/FormField';
34
36
  export { PageHeader } from './components/PageHeader/PageHeader';
35
37
  export type { PageHeaderProps } from './components/PageHeader/PageHeader';
36
38
  export { EmptyState } from './components/EmptyState/EmptyState';
@@ -43,3 +45,7 @@ export { NavTabs } from './components/NavTabs/NavTabs';
43
45
  export type { NavTabsProps, NavTabItem } from './components/NavTabs/NavTabs';
44
46
  export { Tooltip } from './components/Tooltip/Tooltip';
45
47
  export type { TooltipProps } from './components/Tooltip/Tooltip';
48
+ export { Checkbox } from './components/Checkbox/Checkbox';
49
+ export type { CheckboxProps } from './components/Checkbox/Checkbox';
50
+ export { DragHandleRow } from './components/DragHandleRow/DragHandleRow';
51
+ export type { DragHandleRowProps } from './components/DragHandleRow/DragHandleRow';
@@ -2,15 +2,21 @@
2
2
  // open simultaneously; the body stays locked until all of them close.
3
3
  // Uses data-scroll-locks on <body> as a counter so concurrent callers
4
4
  // don't clobber each other's lock state.
5
+ //
6
+ // Only locks overflow-y (not the shorthand overflow) so the stylesheet's
7
+ // overflow-x: hidden on html,body is never overridden. On Android, clearing
8
+ // the overflow shorthand causes a brief frame where overflow-x is also
9
+ // unlocked — content wider than the viewport can paint and the home screen
10
+ // appears to "blow out" after the overlay closes.
5
11
 
6
12
  export function lockBodyScroll(): void {
7
13
  const count = parseInt(document.body.dataset.scrollLocks ?? '0', 10);
8
14
  document.body.dataset.scrollLocks = String(count + 1);
9
- if (count === 0) document.body.style.overflow = 'hidden';
15
+ if (count === 0) document.body.style.overflowY = 'hidden';
10
16
  }
11
17
 
12
18
  export function unlockBodyScroll(): void {
13
19
  const next = Math.max(0, parseInt(document.body.dataset.scrollLocks ?? '0', 10) - 1);
14
20
  document.body.dataset.scrollLocks = String(next);
15
- if (next === 0) document.body.style.overflow = '';
21
+ if (next === 0) document.body.style.overflowY = '';
16
22
  }
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { Checkbox } from '../components/Checkbox/Checkbox';
4
+
5
+ const meta: Meta<typeof Checkbox> = {
6
+ title: 'Components/Checkbox',
7
+ component: Checkbox,
8
+ parameters: { layout: 'centered' },
9
+ tags: ['autodocs'],
10
+ };
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof Checkbox>;
14
+
15
+ export const Default: Story = {
16
+ render: () => {
17
+ const [checked, setChecked] = useState(false);
18
+ return <Checkbox checked={checked} onChange={setChecked} label="Task title" />;
19
+ },
20
+ };
21
+
22
+ export const Checked: Story = {
23
+ render: () => {
24
+ const [checked, setChecked] = useState(true);
25
+ return <Checkbox checked={checked} onChange={setChecked} label="Already done" />;
26
+ },
27
+ };
28
+
29
+ export const WithStrikeThrough: Story = {
30
+ name: 'Strike-through on complete',
31
+ render: () => {
32
+ const [checked, setChecked] = useState(false);
33
+ return (
34
+ <Checkbox checked={checked} onChange={setChecked} label="Click to complete" strikeThrough />
35
+ );
36
+ },
37
+ };
38
+
39
+ export const Disabled: Story = {
40
+ render: () => <Checkbox checked={false} onChange={() => {}} label="Cannot interact" disabled />,
41
+ };
42
+
43
+ export const DisabledChecked: Story = {
44
+ render: () => <Checkbox checked onChange={() => {}} label="Locked complete" disabled />,
45
+ };