@adecore/ui 0.21.2 → 0.22.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.
@@ -11,7 +11,8 @@ export interface SegmentedProps<T extends string> {
11
11
  options: readonly SegmentedOption<T>[];
12
12
  label: string;
13
13
  disabled?: boolean;
14
+ orientation?: 'horizontal' | 'vertical';
14
15
  className?: string;
15
16
  ref?: Ref<HTMLDivElement>;
16
17
  }
17
- export declare function Segmented<T extends string>({ value, onValueChange, options, label, disabled, className, ref }: SegmentedProps<T>): import("react").JSX.Element;
18
+ export declare function Segmented<T extends string>({ value, onValueChange, options, label, disabled, orientation, className, ref }: SegmentedProps<T>): import("react").JSX.Element;
package/dist/Segmented.js CHANGED
@@ -1,7 +1,29 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
3
  import { Icon } from './Icon.js';
4
- /* One of a few options side by side in one sunken track, the picked one lifted out of it. */
5
- export function Segmented({ value, onValueChange, options, label, disabled, className, ref }) {
6
- return (_jsx("div", { ref: ref, className: clsx('flex h-8 w-fit items-center rounded-lg bg-surface-sunken p-0.5 text-xs font-medium', className), role: "radiogroup", "aria-label": label, children: options.map((option) => (_jsxs("button", { type: "button", role: "radio", "aria-checked": value === option.id, disabled: disabled, className: clsx('flex h-7 items-center gap-1.5 rounded-md px-3 transition-colors disabled:opacity-50', value === option.id ? 'bg-surface-raised text-text shadow-raised' : 'text-text-muted hover:text-text'), onClick: () => onValueChange(option.id), children: [option.icon && _jsx(Icon, { icon: option.icon, size: 14 }), option.label] }, option.id))) }));
4
+ const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
5
+ /*
6
+ * One of a few options side by side in one sunken track, the picked one lifted out of it. A radio
7
+ * group: one tab stop, and the arrows pick and focus the next option, around the ends.
8
+ */
9
+ export function Segmented({ value, onValueChange, options, label, disabled, orientation = 'horizontal', className, ref }) {
10
+ const vertical = orientation === 'vertical';
11
+ const picked = options.findIndex((option) => option.id === value);
12
+ const stop = picked === -1 ? 0 : picked;
13
+ function move(event, index) {
14
+ const next = event.key in STEPS
15
+ ? (index + STEPS[event.key] + options.length) % options.length
16
+ : event.key === 'Home'
17
+ ? 0
18
+ : event.key === 'End'
19
+ ? options.length - 1
20
+ : null;
21
+ if (next === null) {
22
+ return;
23
+ }
24
+ event.preventDefault();
25
+ onValueChange(options[next].id);
26
+ event.currentTarget.parentElement?.querySelectorAll('[role="radio"]')[next]?.focus();
27
+ }
28
+ return (_jsx("div", { ref: ref, className: clsx('flex rounded-lg bg-surface-sunken p-0.5 text-xs font-medium', vertical ? 'w-full flex-col items-stretch gap-0.5' : 'min-h-8 w-fit items-center', className), role: "radiogroup", "aria-label": label, "aria-orientation": vertical ? 'vertical' : undefined, children: options.map((option, index) => (_jsxs("button", { type: "button", role: "radio", "aria-checked": value === option.id, tabIndex: index === stop ? 0 : -1, disabled: disabled, className: clsx('flex min-h-7 items-center gap-1.5 rounded-md px-3 py-1 text-left transition-colors disabled:opacity-50', value === option.id ? 'bg-surface-raised text-text shadow-raised' : 'text-text-muted hover:text-text'), onClick: () => onValueChange(option.id), onKeyDown: (event) => move(event, index), children: [option.icon && _jsx(Icon, { icon: option.icon, size: 14, className: "shrink-0" }), option.label] }, option.id))) }));
7
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/ui",
3
- "version": "0.21.2",
3
+ "version": "0.22.1",
4
4
  "description": "React components, a theme, formatters and a settings dialog for desktop-grade apps on React 19, Base UI and Tailwind 4.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
@@ -85,7 +85,6 @@
85
85
  "@types/react-dom": "^19.3.0",
86
86
  "i18next": "^26.4.2",
87
87
  "opentype.js": "2.0.0",
88
- "oxc-parser": "^0.151.0",
89
88
  "react": "^19.3.0",
90
89
  "react-dom": "^19.3.0",
91
90
  "react-i18next": "^17.0.15",
package/src/Segmented.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { Ref } from 'react';
1
+ import type { KeyboardEvent, Ref } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import type { LucideIcon } from 'lucide-react';
4
4
  import { Icon } from './Icon.tsx';
@@ -16,33 +16,68 @@ export interface SegmentedProps<T extends string> {
16
16
  /* The accessible name of the group. */
17
17
  label: string;
18
18
  disabled?: boolean;
19
+ /* `vertical` stacks the options in a column as wide as its container, for labels too long to stand side by side. */
20
+ orientation?: 'horizontal' | 'vertical';
19
21
  className?: string;
20
22
  ref?: Ref<HTMLDivElement>;
21
23
  }
22
24
 
23
- /* One of a few options side by side in one sunken track, the picked one lifted out of it. */
24
- export function Segmented<T extends string>({ value, onValueChange, options, label, disabled, className, ref }: SegmentedProps<T>) {
25
+ const STEPS: Readonly<Record<string, number>> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
26
+
27
+ /*
28
+ * One of a few options side by side in one sunken track, the picked one lifted out of it. A radio
29
+ * group: one tab stop, and the arrows pick and focus the next option, around the ends.
30
+ */
31
+ export function Segmented<T extends string>({ value, onValueChange, options, label, disabled, orientation = 'horizontal', className, ref }: SegmentedProps<T>) {
32
+ const vertical = orientation === 'vertical';
33
+ const picked = options.findIndex((option) => option.id === value);
34
+ const stop = picked === -1 ? 0 : picked;
35
+
36
+ function move(event: KeyboardEvent<HTMLButtonElement>, index: number) {
37
+ const next =
38
+ event.key in STEPS
39
+ ? (index + STEPS[event.key]! + options.length) % options.length
40
+ : event.key === 'Home'
41
+ ? 0
42
+ : event.key === 'End'
43
+ ? options.length - 1
44
+ : null;
45
+ if (next === null) {
46
+ return;
47
+ }
48
+ event.preventDefault();
49
+ onValueChange(options[next]!.id);
50
+ event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus();
51
+ }
52
+
25
53
  return (
26
54
  <div
27
55
  ref={ref}
28
- className={clsx('flex h-8 w-fit items-center rounded-lg bg-surface-sunken p-0.5 text-xs font-medium', className)}
56
+ className={clsx(
57
+ 'flex rounded-lg bg-surface-sunken p-0.5 text-xs font-medium',
58
+ vertical ? 'w-full flex-col items-stretch gap-0.5' : 'min-h-8 w-fit items-center',
59
+ className
60
+ )}
29
61
  role="radiogroup"
30
62
  aria-label={label}
63
+ aria-orientation={vertical ? 'vertical' : undefined}
31
64
  >
32
- {options.map((option) => (
65
+ {options.map((option, index) => (
33
66
  <button
34
67
  key={option.id}
35
68
  type="button"
36
69
  role="radio"
37
70
  aria-checked={value === option.id}
71
+ tabIndex={index === stop ? 0 : -1}
38
72
  disabled={disabled}
39
73
  className={clsx(
40
- 'flex h-7 items-center gap-1.5 rounded-md px-3 transition-colors disabled:opacity-50',
74
+ 'flex min-h-7 items-center gap-1.5 rounded-md px-3 py-1 text-left transition-colors disabled:opacity-50',
41
75
  value === option.id ? 'bg-surface-raised text-text shadow-raised' : 'text-text-muted hover:text-text'
42
76
  )}
43
77
  onClick={() => onValueChange(option.id)}
78
+ onKeyDown={(event) => move(event, index)}
44
79
  >
45
- {option.icon && <Icon icon={option.icon} size={14} />}
80
+ {option.icon && <Icon icon={option.icon} size={14} className="shrink-0" />}
46
81
  {option.label}
47
82
  </button>
48
83
  ))}