@twentyfirstmedia/ui-core 0.4.0 → 0.6.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 (154) hide show
  1. package/ICONS-LICENSE +175 -0
  2. package/dist/components/admin.d.ts.map +1 -1
  3. package/dist/components/admin.js +31 -21
  4. package/dist/components/admin.js.map +1 -1
  5. package/dist/components/agents.d.ts.map +1 -1
  6. package/dist/components/agents.js +21 -18
  7. package/dist/components/agents.js.map +1 -1
  8. package/dist/components/app-chrome.d.ts.map +1 -1
  9. package/dist/components/app-chrome.js +5 -5
  10. package/dist/components/app-chrome.js.map +1 -1
  11. package/dist/components/approvals.d.ts.map +1 -1
  12. package/dist/components/approvals.js +2 -2
  13. package/dist/components/approvals.js.map +1 -1
  14. package/dist/components/building-blocks.d.ts.map +1 -1
  15. package/dist/components/building-blocks.js +13 -10
  16. package/dist/components/building-blocks.js.map +1 -1
  17. package/dist/components/charts.d.ts +2 -2
  18. package/dist/components/charts.d.ts.map +1 -1
  19. package/dist/components/charts.js +14 -11
  20. package/dist/components/charts.js.map +1 -1
  21. package/dist/components/chat.d.ts +2 -2
  22. package/dist/components/chat.d.ts.map +1 -1
  23. package/dist/components/chat.js +22 -18
  24. package/dist/components/chat.js.map +1 -1
  25. package/dist/components/commerce.d.ts.map +1 -1
  26. package/dist/components/commerce.js +20 -16
  27. package/dist/components/commerce.js.map +1 -1
  28. package/dist/components/data-display.d.ts +0 -5
  29. package/dist/components/data-display.d.ts.map +1 -1
  30. package/dist/components/data-display.js +41 -22
  31. package/dist/components/data-display.js.map +1 -1
  32. package/dist/components/data-table.d.ts +2 -2
  33. package/dist/components/data-table.d.ts.map +1 -1
  34. package/dist/components/data-table.js +3 -3
  35. package/dist/components/data-table.js.map +1 -1
  36. package/dist/components/developer.d.ts.map +1 -1
  37. package/dist/components/developer.js +7 -6
  38. package/dist/components/developer.js.map +1 -1
  39. package/dist/components/feedback.d.ts.map +1 -1
  40. package/dist/components/feedback.js +5 -4
  41. package/dist/components/feedback.js.map +1 -1
  42. package/dist/components/forms.d.ts.map +1 -1
  43. package/dist/components/forms.js +63 -21
  44. package/dist/components/forms.js.map +1 -1
  45. package/dist/components/foundation.d.ts.map +1 -1
  46. package/dist/components/foundation.js +13 -9
  47. package/dist/components/foundation.js.map +1 -1
  48. package/dist/components/index.d.ts +2 -1
  49. package/dist/components/index.d.ts.map +1 -1
  50. package/dist/components/index.js +2 -1
  51. package/dist/components/index.js.map +1 -1
  52. package/dist/components/inputs.d.ts.map +1 -1
  53. package/dist/components/inputs.js +33 -8
  54. package/dist/components/inputs.js.map +1 -1
  55. package/dist/components/mail.d.ts.map +1 -1
  56. package/dist/components/mail.js +5 -5
  57. package/dist/components/mail.js.map +1 -1
  58. package/dist/components/media.d.ts.map +1 -1
  59. package/dist/components/media.js +62 -11
  60. package/dist/components/media.js.map +1 -1
  61. package/dist/components/navigation.d.ts +3 -1
  62. package/dist/components/navigation.d.ts.map +1 -1
  63. package/dist/components/navigation.js +287 -20
  64. package/dist/components/navigation.js.map +1 -1
  65. package/dist/components/operations.d.ts.map +1 -1
  66. package/dist/components/operations.js +10 -10
  67. package/dist/components/operations.js.map +1 -1
  68. package/dist/components/overlays.d.ts.map +1 -1
  69. package/dist/components/overlays.js +2 -2
  70. package/dist/components/overlays.js.map +1 -1
  71. package/dist/components/previews.js +2 -2
  72. package/dist/components/previews.js.map +1 -1
  73. package/dist/components/primitives.js +2 -2
  74. package/dist/components/primitives.js.map +1 -1
  75. package/dist/components/qr-code.d.ts +8 -0
  76. package/dist/components/qr-code.d.ts.map +1 -0
  77. package/dist/components/qr-code.js +18 -0
  78. package/dist/components/qr-code.js.map +1 -0
  79. package/dist/components/real-estate.d.ts.map +1 -1
  80. package/dist/components/real-estate.js +5 -5
  81. package/dist/components/real-estate.js.map +1 -1
  82. package/dist/components/rich-text-editor.d.ts.map +1 -1
  83. package/dist/components/rich-text-editor.js +1 -1
  84. package/dist/components/rich-text-editor.js.map +1 -1
  85. package/dist/components/select-menu.js +1 -1
  86. package/dist/components/select-menu.js.map +1 -1
  87. package/dist/components/shell.d.ts +0 -4
  88. package/dist/components/shell.d.ts.map +1 -1
  89. package/dist/components/shell.js +100 -7
  90. package/dist/components/shell.js.map +1 -1
  91. package/dist/components/site.d.ts.map +1 -1
  92. package/dist/components/site.js +8 -6
  93. package/dist/components/site.js.map +1 -1
  94. package/dist/components/status.d.ts.map +1 -1
  95. package/dist/components/status.js +2 -2
  96. package/dist/components/status.js.map +1 -1
  97. package/dist/components/system.d.ts.map +1 -1
  98. package/dist/components/system.js +3 -2
  99. package/dist/components/system.js.map +1 -1
  100. package/dist/components/types.d.ts +257 -18
  101. package/dist/components/types.d.ts.map +1 -1
  102. package/dist/components/workspace.d.ts.map +1 -1
  103. package/dist/components/workspace.js +15 -12
  104. package/dist/components/workspace.js.map +1 -1
  105. package/dist/icon-library.d.ts +254 -3
  106. package/dist/icon-library.d.ts.map +1 -1
  107. package/dist/icon-library.js +518 -14
  108. package/dist/icon-library.js.map +1 -1
  109. package/dist/qr.d.ts +14 -0
  110. package/dist/qr.d.ts.map +1 -0
  111. package/dist/qr.js +298 -0
  112. package/dist/qr.js.map +1 -0
  113. package/dist/strings.d.ts +30 -4
  114. package/dist/strings.d.ts.map +1 -1
  115. package/dist/strings.js +15 -0
  116. package/dist/strings.js.map +1 -1
  117. package/dist/themes.css +2345 -176
  118. package/package.json +2 -1
  119. package/src/components/admin.tsx +66 -23
  120. package/src/components/agents.tsx +22 -20
  121. package/src/components/app-chrome.tsx +9 -5
  122. package/src/components/approvals.tsx +3 -1
  123. package/src/components/building-blocks.tsx +24 -13
  124. package/src/components/charts.tsx +17 -12
  125. package/src/components/chat.tsx +54 -31
  126. package/src/components/commerce.tsx +58 -24
  127. package/src/components/data-display.tsx +106 -38
  128. package/src/components/data-table.tsx +11 -7
  129. package/src/components/developer.tsx +15 -6
  130. package/src/components/feedback.tsx +5 -3
  131. package/src/components/forms.tsx +101 -23
  132. package/src/components/foundation.tsx +9 -5
  133. package/src/components/index.ts +10 -1
  134. package/src/components/inputs.tsx +44 -7
  135. package/src/components/mail.tsx +7 -3
  136. package/src/components/media.tsx +88 -16
  137. package/src/components/navigation.tsx +651 -71
  138. package/src/components/operations.tsx +17 -8
  139. package/src/components/overlays.tsx +7 -2
  140. package/src/components/previews.tsx +2 -2
  141. package/src/components/primitives.tsx +2 -2
  142. package/src/components/qr-code.tsx +30 -0
  143. package/src/components/real-estate.tsx +7 -4
  144. package/src/components/rich-text-editor.tsx +7 -1
  145. package/src/components/select-menu.tsx +1 -1
  146. package/src/components/shell.tsx +237 -11
  147. package/src/components/site.tsx +18 -4
  148. package/src/components/status.tsx +2 -0
  149. package/src/components/system.tsx +3 -2
  150. package/src/components/types.ts +261 -18
  151. package/src/components/workspace.tsx +37 -15
  152. package/src/icon-library.ts +523 -18
  153. package/src/qr.ts +300 -0
  154. package/src/strings.ts +56 -1
@@ -10,6 +10,11 @@ import { Icon } from "../Icon.js";
10
10
  import { useControlled, useId, useOutside } from "../hooks.js";
11
11
  import { Avatar, IconButton, TintIcon } from "./foundation.js";
12
12
  import { Popover } from "./overlays.js";
13
+ import { registerIcons } from "../icons.js";
14
+ import { eyeOff } from "../icon-library.js";
15
+
16
+ // the password field's "hide" state needs this one library icon
17
+ registerIcons({ eyeOff });
13
18
  import type {
14
19
  CalendarProps,
15
20
  ComboboxProps,
@@ -69,12 +74,30 @@ export function FormField(p: FormFieldProps) {
69
74
  }
70
75
 
71
76
  export function TextField(p: TextFieldProps) {
77
+ const t = useStrings(FORMS_STRINGS);
78
+ const [shown, setShown] = React.useState(false);
79
+ const password = p.type === "password";
80
+ const action =
81
+ p.action ??
82
+ (password && p.reveal !== false ? (
83
+ <IconButton
84
+ icon={shown ? "eyeOff" : "eye"}
85
+ label={shown ? t.hidePassword : t.showPassword}
86
+ size="sm"
87
+ variant="ghost"
88
+ aria-pressed={shown}
89
+ disabled={p.disabled}
90
+ onClick={() => setShown(!shown)}
91
+ />
92
+ ) : null);
72
93
  const auto = useId("tf");
73
94
  const id = p.id || auto;
74
95
  const fromForm = useFieldError(p.name);
75
96
  const error = p.error || fromForm;
76
97
  return (
77
98
  <FormField
99
+ style={p.style}
100
+ className={p.className}
78
101
  id={id}
79
102
  label={p.label}
80
103
  required={p.required}
@@ -88,6 +111,7 @@ export function TextField(p: TextFieldProps) {
88
111
  "tf-inputwrap",
89
112
  p.icon && "has-icon",
90
113
  (error || p.success || p.suffix) && "has-suffix",
114
+ action && "has-action",
91
115
  )}
92
116
  >
93
117
  {p.icon && <Icon name={p.icon} size={15} className="tf-input-icon" />}
@@ -95,12 +119,13 @@ export function TextField(p: TextFieldProps) {
95
119
  id={id}
96
120
  name={p.name}
97
121
  className={cx("tf-input", p.success && "is-valid")}
98
- type={p.type || "text"}
122
+ type={password && shown ? "text" : p.type || "text"}
99
123
  placeholder={p.placeholder}
100
124
  value={p.value}
101
125
  defaultValue={p.defaultValue}
102
126
  onChange={p.onChange}
103
127
  onBlur={p.onBlur}
128
+ onKeyDown={p.onKeyDown}
104
129
  required={p.required}
105
130
  disabled={p.disabled}
106
131
  readOnly={p.readOnly}
@@ -127,6 +152,7 @@ export function TextField(p: TextFieldProps) {
127
152
  ) : (
128
153
  p.suffix && <span className="tf-input-suffix">{p.suffix}</span>
129
154
  )}
155
+ {action && <span className="tf-input-action">{action}</span>}
130
156
  </span>
131
157
  </FormField>
132
158
  );
@@ -139,7 +165,7 @@ export function RadioGroup(p: RadioGroupProps) {
139
165
  const name = useId("rg"),
140
166
  v = useControlled(p.value, p.defaultValue, p.onChange);
141
167
  return (
142
- <fieldset className={cx("tf-radiogroup", p.cards && "cards", p.className)}>
168
+ <fieldset style={p.style} className={cx("tf-radiogroup", p.cards && "cards", p.className)}>
143
169
  {p.label && <legend className="tf-field-label">{p.label}</legend>}
144
170
  <div className="tf-radiogroup-items">
145
171
  {(p.options || []).map((o) => {
@@ -189,6 +215,7 @@ export function Segmented(p: SegmentedProps) {
189
215
  name = useId("seg");
190
216
  return (
191
217
  <div
218
+ style={p.style}
192
219
  className={cx("tf-seg", p.size === "sm" && "sm", p.block && "block", p.className)}
193
220
  role="radiogroup"
194
221
  aria-label={p.label}
@@ -204,16 +231,20 @@ export function Segmented(p: SegmentedProps) {
204
231
  on = v[0] === opt.value;
205
232
  return (
206
233
  <label key={opt.value} className={cx("tf-seg-item", on && "on")}>
207
- <input
208
- type="radio"
209
- name={name}
210
- checked={on}
211
- onChange={() => {
212
- v[1](opt.value);
213
- }}
214
- />
215
234
  {opt.icon && <Icon name={opt.icon} size={14} />}
216
- {opt.label}
235
+ {/* the radio covers the whole segment (positioned on the label); inside the text's
236
+ span, a click on the text and a click on the radio hit the same target */}
237
+ <span className="tf-seg-label">
238
+ <input
239
+ type="radio"
240
+ name={name}
241
+ checked={on}
242
+ onChange={() => {
243
+ v[1](opt.value);
244
+ }}
245
+ />
246
+ {opt.label}
247
+ </span>
217
248
  </label>
218
249
  );
219
250
  })}
@@ -301,6 +332,7 @@ export function Combobox(p: ComboboxProps) {
301
332
  };
302
333
  return (
303
334
  <FormField
335
+ style={p.style}
304
336
  id={id}
305
337
  label={p.label}
306
338
  required={p.required}
@@ -442,7 +474,14 @@ export function TagInput(p: TagInputProps) {
442
474
  q[1]("");
443
475
  }
444
476
  return (
445
- <FormField id={id} label={p.label} hint={p.hint} error={p.error}>
477
+ <FormField
478
+ style={p.style}
479
+ className={p.className}
480
+ id={id}
481
+ label={p.label}
482
+ hint={p.hint}
483
+ error={p.error}
484
+ >
446
485
  <div
447
486
  className="tf-combo-box tf-tags"
448
487
  onClick={(e) => {
@@ -562,7 +601,7 @@ export function Calendar(p: CalendarProps) {
562
601
  m[1](new Date(first.getFullYear(), first.getMonth() + n, 1));
563
602
  }
564
603
  return (
565
- <div className="tf-cal">
604
+ <div style={p.style} className={cx("tf-cal", p.className)}>
566
605
  <div className="tf-cal-head">
567
606
  <IconButton
568
607
  icon="chevronLeft"
@@ -671,6 +710,7 @@ export function DatePicker(p: DatePickerProps) {
671
710
  : fmtD(v[0] as Date | null, locale);
672
711
  return (
673
712
  <FormField
713
+ style={p.style}
674
714
  id={id}
675
715
  label={p.label}
676
716
  required={p.required}
@@ -756,7 +796,14 @@ export function NumberInput(p: NumberInputProps) {
756
796
  v[1](Math.round(n * 1000) / 1000);
757
797
  }
758
798
  return (
759
- <FormField id={id} label={p.label} hint={p.hint} error={p.error}>
799
+ <FormField
800
+ style={p.style}
801
+ className={p.className}
802
+ id={id}
803
+ label={p.label}
804
+ hint={p.hint}
805
+ error={p.error}
806
+ >
760
807
  <div className="tf-number">
761
808
  <IconButton
762
809
  icon="minus"
@@ -811,7 +858,7 @@ export function Slider(p: SliderProps) {
811
858
  const pct = ((v[0] - min) / (max - min)) * 100,
812
859
  f = p.format || String;
813
860
  return (
814
- <FormField id={id} label={p.label} hint={p.hint}>
861
+ <FormField style={p.style} className={p.className} id={id} label={p.label} hint={p.hint}>
815
862
  <div className="tf-slider">
816
863
  <div className="tf-slider-track">
817
864
  <input
@@ -856,6 +903,16 @@ export function OtpInput(p: OtpInputProps) {
856
903
  const n = p.length || 6,
857
904
  st = React.useState(Array(n).fill("")),
858
905
  refs = React.useRef<(HTMLInputElement | null)[]>([]);
906
+ const reset = React.useCallback(() => {
907
+ st[1](Array(n).fill(""));
908
+ refs.current[0]?.focus();
909
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- st[1] is stable
910
+ }, [n]);
911
+ React.useImperativeHandle(p.ref, () => ({ reset }), [reset]);
912
+ // a wrong code: empty the cells for the next try
913
+ React.useEffect(() => {
914
+ if (p.error && p.clearOnError !== false) reset();
915
+ }, [p.error, p.clearOnError, reset]);
859
916
  function set(i: number, ch: string) {
860
917
  const nx = st[0].slice();
861
918
  nx[i] = ch;
@@ -865,7 +922,8 @@ export function OtpInput(p: OtpInputProps) {
865
922
  }
866
923
  return (
867
924
  <div
868
- className={cx("tf-otp", p.error && "invalid")}
925
+ style={p.style}
926
+ className={cx("tf-otp", p.error && "invalid", p.className)}
869
927
  role="group"
870
928
  aria-label={p.label || t.verificationCode}
871
929
  >
@@ -932,6 +990,19 @@ function acceptsFile(file: { name: string; type: string }, accept?: string): boo
932
990
  );
933
991
  }
934
992
 
993
+ /** A hidden file input that carries the listed files into a surrounding form. */
994
+ function FormFiles(p: { name: string; files: UploadFile[] }) {
995
+ const ref = React.useRef<HTMLInputElement>(null);
996
+ React.useEffect(() => {
997
+ const el = ref.current;
998
+ if (!el || typeof DataTransfer === "undefined") return;
999
+ const dt = new DataTransfer();
1000
+ p.files.forEach((f) => f.file && dt.items.add(f.file));
1001
+ el.files = dt.files;
1002
+ }, [p.files]);
1003
+ return <input ref={ref} type="file" name={p.name} multiple hidden tabIndex={-1} />;
1004
+ }
1005
+
935
1006
  export function FileDrop(p: FileDropProps) {
936
1007
  const t = useStrings(FORMS_STRINGS);
937
1008
  const fm = useFormat();
@@ -955,8 +1026,11 @@ export function FileDrop(p: FileDropProps) {
955
1026
  picked = picked.filter((f) => acceptsFile(f, p.accept));
956
1027
  if (p.multiple === false) picked = picked.slice(0, 1);
957
1028
  if (rejected.length) p.onReject?.(rejected);
1029
+ if (!picked.length || p.disabled) return;
1030
+ const kept = p.onFiles?.(picked);
1031
+ if (kept === false) return;
1032
+ if (Array.isArray(kept)) picked = kept;
958
1033
  if (!picked.length) return;
959
- p.onFiles?.(picked);
960
1034
  const add: UploadFile[] = picked.map((f) => ({
961
1035
  name: f.name,
962
1036
  size: f.size,
@@ -983,27 +1057,29 @@ export function FileDrop(p: FileDropProps) {
983
1057
  }
984
1058
  return (
985
1059
  <div
986
- className="tf-col"
1060
+ className={cx("tf-col", p.className)}
987
1061
  style={{
988
1062
  gap: 12,
1063
+ ...p.style,
989
1064
  }}
990
1065
  >
991
1066
  <div
992
- className={cx("tf-drop", over[0] && "over")}
1067
+ className={cx("tf-drop", over[0] && "over", p.disabled && "disabled")}
993
1068
  role="button"
994
- tabIndex={0}
1069
+ tabIndex={p.disabled ? -1 : 0}
1070
+ aria-disabled={p.disabled || undefined}
995
1071
  onClick={() => {
996
- inp.current?.click();
1072
+ if (!p.disabled) inp.current?.click();
997
1073
  }}
998
1074
  onKeyDown={(e) => {
999
- if (e.key === "Enter" || e.key === " ") {
1075
+ if (!p.disabled && (e.key === "Enter" || e.key === " ")) {
1000
1076
  e.preventDefault();
1001
1077
  inp.current?.click();
1002
1078
  }
1003
1079
  }}
1004
1080
  onDragOver={(e) => {
1005
1081
  e.preventDefault();
1006
- over[1](true);
1082
+ if (!p.disabled) over[1](true);
1007
1083
  }}
1008
1084
  onDragLeave={() => {
1009
1085
  over[1](false);
@@ -1023,11 +1099,13 @@ export function FileDrop(p: FileDropProps) {
1023
1099
  hidden
1024
1100
  multiple={p.multiple !== false}
1025
1101
  accept={p.accept}
1102
+ disabled={p.disabled}
1026
1103
  onChange={(e) => {
1027
1104
  take(e.target.files);
1028
1105
  e.target.value = "";
1029
1106
  }}
1030
1107
  />
1108
+ {p.name && <FormFiles name={p.name} files={files[0]} />}
1031
1109
  </div>
1032
1110
  {files[0].length > 0 && (
1033
1111
  <div
@@ -76,6 +76,7 @@ export function IconButton(props: NativeRest<IconButtonProps>) {
76
76
  export function Chip(p: ChipProps) {
77
77
  return (
78
78
  <button
79
+ style={p.style}
79
80
  type="button"
80
81
  className={cx("tf-chip", p.className)}
81
82
  aria-pressed={p.selected ? "true" : "false"}
@@ -101,6 +102,7 @@ export function Badge(p: BadgeProps) {
101
102
  const tone = (p.tone && TONE_ALIAS[p.tone]) || p.tone || "neutral";
102
103
  return (
103
104
  <span
105
+ style={p.style}
104
106
  className={cx(
105
107
  "tf-badge",
106
108
  "tf-badge-" + tone,
@@ -120,14 +122,15 @@ export function TintIcon(p: TintIconProps) {
120
122
  className={cx("tf-tinticon", "tf-t-" + (p.tint || "plain"), p.className)}
121
123
  aria-hidden={p.label ? undefined : true}
122
124
  title={p.label}
123
- style={
124
- p.size
125
+ style={{
126
+ ...(p.size
125
127
  ? {
126
128
  width: p.size,
127
129
  height: p.size,
128
130
  }
129
- : undefined
130
- }
131
+ : undefined),
132
+ ...p.style,
133
+ }}
131
134
  >
132
135
  <Icon name={p.icon} />
133
136
  </span>
@@ -148,6 +151,7 @@ export function Avatar(p: AvatarProps) {
148
151
  // a custom property, so a kit can recolour avatars (Pro keeps them monochrome)
149
152
  ["--av" as string]: "var(--" + t + ")",
150
153
  fontSize: Math.round(size * 0.36),
154
+ ...p.style,
151
155
  }}
152
156
  title={p.name}
153
157
  >
@@ -161,7 +165,7 @@ export function AvatarStack(p: AvatarStackProps) {
161
165
  max = p.max || 4,
162
166
  extra = names.length - max;
163
167
  return (
164
- <span className="tf-avatar-stack">
168
+ <span style={p.style} className={cx("tf-avatar-stack", p.className)}>
165
169
  {names.slice(0, max).map((n) => (
166
170
  <Avatar key={n} name={n} size={p.size || 28} />
167
171
  ))}
@@ -21,6 +21,7 @@ export {
21
21
  TwoFactorSetup,
22
22
  ColorField,
23
23
  } from "./admin.js";
24
+ export { QrCode } from "./qr-code.js";
24
25
  export {
25
26
  AgentCard,
26
27
  AgentDetails,
@@ -157,7 +158,15 @@ export {
157
158
  AttachmentPreview,
158
159
  } from "./mail.js";
159
160
  export { ImageVariants, Gallery, BeforeAfter } from "./media.js";
160
- export { Tabs, Breadcrumb, Pagination, Menubar, NavigationMenu, Sidebar } from "./navigation.js";
161
+ export {
162
+ Tabs,
163
+ Breadcrumb,
164
+ Pagination,
165
+ Menubar,
166
+ NavigationMenu,
167
+ Sidebar,
168
+ UserMenu,
169
+ } from "./navigation.js";
161
170
  export {
162
171
  ConnectionCard,
163
172
  ConnectionBanner,
@@ -129,24 +129,60 @@ export function Textarea(p: TextareaProps) {
129
129
 
130
130
  export function SearchField(p: SearchFieldProps) {
131
131
  const t = useStrings();
132
+ const input = React.useRef<HTMLInputElement>(null);
133
+ // ⌘K / Ctrl+K: the last character of the hint is the key
134
+ const key = p.bindShortcut && p.shortcut ? p.shortcut.trim().slice(-1).toLowerCase() : "";
135
+ React.useEffect(() => {
136
+ if (!key) return;
137
+ const k = (e: KeyboardEvent) => {
138
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.key.toLowerCase() !== key) return;
139
+ const el = e.target as HTMLElement | null;
140
+ const typing =
141
+ !!el &&
142
+ el !== input.current &&
143
+ (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
144
+ if (typing) return;
145
+ e.preventDefault();
146
+ input.current?.focus();
147
+ input.current?.select();
148
+ };
149
+ document.addEventListener("keydown", k);
150
+ return () => document.removeEventListener("keydown", k);
151
+ }, [key]);
132
152
  return (
133
153
  <label className={cx("tf-search", p.className)} style={p.style}>
134
154
  <Icon name="search" size={15} />
135
155
  <input
156
+ ref={input}
136
157
  type="search"
158
+ id={p.id}
159
+ name={p.name}
160
+ autoFocus={p.autoFocus}
137
161
  placeholder={p.placeholder || t.search}
138
162
  aria-label={p.label || p.placeholder || t.search}
163
+ aria-keyshortcuts={
164
+ key ? `Meta+${key.toUpperCase()} Control+${key.toUpperCase()}` : undefined
165
+ }
139
166
  value={p.value}
167
+ defaultValue={p.defaultValue}
140
168
  onChange={p.onChange}
169
+ onKeyDown={p.onKeyDown}
170
+ onFocus={p.onFocus}
171
+ onBlur={p.onBlur}
172
+ {...p.inputProps}
141
173
  />
142
- {p.shortcut && <span className="tf-kbd">{p.shortcut}</span>}
174
+ {p.shortcut && (
175
+ <span className="tf-kbd" aria-hidden>
176
+ {p.shortcut}
177
+ </span>
178
+ )}
143
179
  </label>
144
180
  );
145
181
  }
146
182
 
147
183
  export function Checkbox(p: CheckboxProps) {
148
184
  return (
149
- <label className="tf-check">
185
+ <label style={p.style} className={cx("tf-check", p.className)}>
150
186
  <span className="tf-checkbox">
151
187
  <input
152
188
  type="checkbox"
@@ -182,15 +218,16 @@ export function Checkbox(p: CheckboxProps) {
182
218
  export function Switch(p: SwitchProps) {
183
219
  return (
184
220
  <label
185
- className="tf-check"
186
- style={
187
- p.spread
221
+ className={cx("tf-check", p.className)}
222
+ style={{
223
+ ...(p.spread
188
224
  ? {
189
225
  justifyContent: "space-between",
190
226
  width: "100%",
191
227
  }
192
- : undefined
193
- }
228
+ : undefined),
229
+ ...p.style,
230
+ }}
194
231
  >
195
232
  {p.spread && p.children}
196
233
  <input
@@ -30,6 +30,8 @@ export function AccountSwitcher(p: AccountSwitcherProps) {
30
30
  const cur = (p.accounts || []).find((a) => a.address === p.value);
31
31
  return (
32
32
  <Popover
33
+ style={p.style}
34
+ className={p.className}
33
35
  width={320}
34
36
  trigger={(s) => (
35
37
  <button
@@ -103,7 +105,7 @@ export function AccountSwitcher(p: AccountSwitcherProps) {
103
105
  export function InboxList(p: InboxListProps) {
104
106
  const t = useStrings(MAIL_STRINGS);
105
107
  return (
106
- <div className="tf-inboxlist" role="list">
108
+ <div style={p.style} className={cx("tf-inboxlist", p.className)} role="list">
107
109
  {(p.items || []).map((m) => (
108
110
  <button
109
111
  key={m.id}
@@ -149,7 +151,7 @@ export function MailThread(p: MailThreadProps) {
149
151
  (p.messages || []).length ? [p.messages[p.messages.length - 1].id] : [],
150
152
  );
151
153
  return (
152
- <div className="tf-thread">
154
+ <div style={p.style} className={cx("tf-thread", p.className)}>
153
155
  {p.subject && <h3 className="tf-thread-subject">{p.subject}</h3>}
154
156
  {(p.messages || []).map((m) => {
155
157
  const o = open[0].indexOf(m.id) > -1;
@@ -213,7 +215,7 @@ export function SignatureEditor(p: SignatureEditorProps) {
213
215
  v[1](n);
214
216
  }
215
217
  return (
216
- <div className="tf-sig">
218
+ <div style={p.style} className={cx("tf-sig", p.className)}>
217
219
  <div
218
220
  className="tf-col"
219
221
  style={{
@@ -257,6 +259,8 @@ export function SignatureEditor(p: SignatureEditorProps) {
257
259
  export function AttachmentPreview(p: AttachmentPreviewProps) {
258
260
  return (
259
261
  <Drawer
262
+ style={p.style}
263
+ className={p.className}
260
264
  title={p.name}
261
265
  subtitle={p.meta}
262
266
  onClose={p.onClose}
@@ -2,7 +2,7 @@
2
2
  import * as React from "react";
3
3
  import { useFormat, useStrings } from "../context.js";
4
4
  import { MEDIA_STRINGS } from "../strings.js";
5
- import { useControlled } from "../hooks.js";
5
+ import { moveFocus, useControlled } from "../hooks.js";
6
6
  import { cx } from "../cx.js";
7
7
  import { Icon } from "../Icon.js";
8
8
  import { Portal } from "../portal.js";
@@ -23,7 +23,7 @@ export function ImageVariants(p: ImageVariantsProps) {
23
23
  const sel = useControlled<string | number>(p.selected, p.variants?.[0]?.id ?? "", p.onSelect),
24
24
  cur: Partial<ImageVariant> = (p.variants || []).find((v) => v.id === sel[0]) || {};
25
25
  return (
26
- <div className="tf-imgvar">
26
+ <div style={p.style} className={cx("tf-imgvar", p.className)}>
27
27
  <div
28
28
  className="tf-imgvar-stage"
29
29
  style={
@@ -94,19 +94,52 @@ export function ImageVariants(p: ImageVariantsProps) {
94
94
  export function Gallery(p: GalleryProps) {
95
95
  const t = useStrings(MEDIA_STRINGS);
96
96
  const items = p.items || [],
97
- i = React.useState(p.start || 0),
97
+ i = useControlled(p.index, p.start || 0, p.onIndexChange),
98
98
  full = React.useState(false),
99
99
  n = items.length,
100
100
  cur: GalleryItem = items[i[0]] || {};
101
+ const box = React.useRef<HTMLDivElement>(null),
102
+ thumbs = React.useRef<HTMLDivElement>(null),
103
+ opener = React.useRef<HTMLButtonElement>(null);
101
104
  function go(d: number) {
102
105
  i[1]((i[0] + d + n) % n);
103
106
  }
107
+ const restore = React.useRef(false);
108
+ function close() {
109
+ full[1](false);
110
+ restore.current = true;
111
+ }
112
+ // the full-screen button comes back after the lightbox closes: focus returns to it
113
+ React.useEffect(() => {
114
+ if (!full[0] && restore.current) {
115
+ restore.current = false;
116
+ opener.current?.focus();
117
+ }
118
+ }, [full[0]]);
119
+ // focus moves into the lightbox when it appears (the portal mounts a render later)
120
+ const attachBox = React.useCallback((n: HTMLDivElement | null) => {
121
+ box.current = n;
122
+ n?.querySelector<HTMLElement>("button")?.focus();
123
+ }, []);
104
124
  React.useEffect(() => {
105
125
  if (!full[0]) return;
126
+ const lb = box.current;
106
127
  function k(e: KeyboardEvent) {
107
- if (e.key === "Escape") full[1](false);
128
+ if (e.key === "Escape") close();
108
129
  if (e.key === "ArrowRight") go(1);
109
130
  if (e.key === "ArrowLeft") go(-1);
131
+ if (e.key === "Tab" && lb) {
132
+ const els = Array.from(lb.querySelectorAll<HTMLElement>("button"));
133
+ const a = els[0],
134
+ z = els[els.length - 1];
135
+ if (e.shiftKey && document.activeElement === a) {
136
+ e.preventDefault();
137
+ z?.focus();
138
+ } else if (!e.shiftKey && document.activeElement === z) {
139
+ e.preventDefault();
140
+ a?.focus();
141
+ }
142
+ }
110
143
  }
111
144
  document.addEventListener("keydown", k);
112
145
  return () => {
@@ -132,6 +165,19 @@ export function Gallery(p: GalleryProps) {
132
165
  const stage = (
133
166
  <div
134
167
  className="tf-gallery-stage"
168
+ role="group"
169
+ aria-label={t.imageN(i[0] + 1)}
170
+ tabIndex={n > 1 ? 0 : undefined}
171
+ onKeyDown={(e) => {
172
+ if (e.target !== e.currentTarget) return;
173
+ if (e.key === "ArrowRight") {
174
+ e.preventDefault();
175
+ go(1);
176
+ } else if (e.key === "ArrowLeft") {
177
+ e.preventDefault();
178
+ go(-1);
179
+ }
180
+ }}
135
181
  style={{
136
182
  aspectRatio: p.ratio || "3 / 2",
137
183
  }}
@@ -160,6 +206,7 @@ export function Gallery(p: GalleryProps) {
160
206
  <span className="tf-gallery-count">{i[0] + 1 + " / " + n}</span>
161
207
  {!full[0] && p.fullscreen !== false && (
162
208
  <IconButton
209
+ ref={opener}
163
210
  icon="expand"
164
211
  label={t.fullScreen}
165
212
  className="tf-gallery-full"
@@ -172,16 +219,35 @@ export function Gallery(p: GalleryProps) {
172
219
  </div>
173
220
  );
174
221
  return (
175
- <div className="tf-gallery">
222
+ <div
223
+ style={p.style}
224
+ className={cx("tf-gallery", p.className)}
225
+ role="group"
226
+ aria-label={p.label || t.images}
227
+ >
176
228
  {stage}
177
229
  {p.thumbs !== false && n > 1 && (
178
- <div className="tf-gallery-thumbs" role="tablist" aria-label={t.images}>
230
+ <div
231
+ ref={thumbs}
232
+ className="tf-gallery-thumbs"
233
+ role="tablist"
234
+ aria-label={p.label || t.images}
235
+ onKeyDown={(e) => {
236
+ if (moveFocus(e, thumbs.current, '[role="tab"]', "horizontal")) {
237
+ const k = Array.from(thumbs.current?.querySelectorAll('[role="tab"]') || []).indexOf(
238
+ document.activeElement as Element,
239
+ );
240
+ if (k > -1) i[1](k);
241
+ }
242
+ }}
243
+ >
179
244
  {items.map((it, k) => (
180
245
  <button
181
246
  key={k}
182
247
  type="button"
183
248
  role="tab"
184
249
  aria-selected={k === i[0]}
250
+ tabIndex={k === i[0] ? 0 : -1}
185
251
  className={cx(k === i[0] && "on")}
186
252
  onClick={() => {
187
253
  i[1](k);
@@ -195,15 +261,14 @@ export function Gallery(p: GalleryProps) {
195
261
  )}
196
262
  {full[0] && (
197
263
  <Portal>
198
- <div className="tf-lightbox" role="dialog" aria-modal="true" aria-label={t.images}>
199
- <IconButton
200
- icon="x"
201
- label={t.close}
202
- className="tf-lightbox-close"
203
- onClick={() => {
204
- full[1](false);
205
- }}
206
- />
264
+ <div
265
+ ref={attachBox}
266
+ className="tf-lightbox"
267
+ role="dialog"
268
+ aria-modal="true"
269
+ aria-label={p.label || t.images}
270
+ >
271
+ <IconButton icon="x" label={t.close} className="tf-lightbox-close" onClick={close} />
207
272
  <div className="tf-lightbox-stage">
208
273
  {pic(cur, "tf-gallery-img", "eager")}
209
274
  {n > 1 && (
@@ -268,9 +333,10 @@ export function BeforeAfter(p: BeforeAfterProps) {
268
333
  return (
269
334
  <div
270
335
  ref={ref}
271
- className="tf-ba"
336
+ className={cx("tf-ba", p.className)}
272
337
  style={{
273
338
  aspectRatio: p.ratio || "3 / 2",
339
+ ...p.style,
274
340
  }}
275
341
  onPointerDown={(e) => {
276
342
  drag.current = true;
@@ -283,6 +349,12 @@ export function BeforeAfter(p: BeforeAfterProps) {
283
349
  onPointerUp={() => {
284
350
  drag.current = false;
285
351
  }}
352
+ onPointerCancel={() => {
353
+ drag.current = false;
354
+ }}
355
+ onLostPointerCapture={() => {
356
+ drag.current = false;
357
+ }}
286
358
  >
287
359
  {pane(p.after, "tf-ba-img")}
288
360
  <div