@womp/kookie-ui 0.11.0 → 0.12.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.
@@ -8,7 +8,9 @@
8
8
 
9
9
  /* Defaults */
10
10
  .rt-ChatbarRoot {
11
- --chatbar-attachment-thumb: var(--space-8);
11
+ --chatbar-item-lead: var(--space-5);
12
+ --chatbar-item-lead-radius: var(--radius-2);
13
+ --chatbar-item-padding: var(--space-1);
12
14
  /* A column: notices, then the box (which fills any height the consumer gives the root), then context. */
13
15
  display: flex;
14
16
  flex-direction: column;
@@ -116,58 +118,97 @@
116
118
  --scrollarea-scrollbar-size: 3px;
117
119
  }
118
120
 
119
- /* Attachment tile now uses Card + Flex, no custom layout needed */
121
+ /*
122
+ * Attachments and items are one pill: a lead tile (thumbnail or tinted icon),
123
+ * a label and a remove button. A custom `renderAttachment` keeps only the
124
+ * plain .rt-ChatbarAttachment hook.
125
+ */
126
+ .rt-ChatbarAttachment {
127
+ position: relative;
128
+ }
129
+
130
+ .rt-ChatbarItem {
131
+ display: inline-flex;
132
+ align-items: center;
133
+ gap: var(--space-2);
134
+ flex-shrink: 0;
135
+ box-sizing: border-box;
136
+ max-width: 240px;
137
+ padding: var(--chatbar-item-padding);
138
+ padding-right: calc(var(--chatbar-item-padding) + var(--space-1));
139
+ border: 1px solid var(--gray-a5);
140
+ border-radius: calc(var(--chatbar-item-lead-radius) + var(--chatbar-item-padding));
141
+ background-color: var(--color-panel-solid);
142
+ box-shadow: 0 1px 2px var(--gray-a3);
143
+ color: var(--gray-12);
144
+ }
145
+
146
+ .rt-ChatbarItem:where([data-status='error']) {
147
+ border-color: var(--red-a7);
148
+ }
120
149
 
121
- .rt-ChatbarAttachmentPreview {
122
- height: var(--chatbar-attachment-thumb);
123
- width: var(--chatbar-attachment-thumb);
124
- min-width: var(--chatbar-attachment-thumb);
150
+ .rt-ChatbarItemLead {
151
+ height: var(--chatbar-item-lead);
152
+ width: var(--chatbar-item-lead);
125
153
  flex-shrink: 0;
126
- border-radius: var(--radius-1);
154
+ border-radius: var(--chatbar-item-lead-radius);
127
155
  overflow: hidden;
128
156
  display: inline-flex;
129
157
  align-items: center;
130
158
  justify-content: center;
159
+ background: var(--accent-a3);
160
+ color: var(--accent-11);
161
+
162
+ /* stylelint-disable-next-line selector-max-type */
163
+ & :where(svg) {
164
+ width: calc(var(--chatbar-item-lead) * 0.6);
165
+ height: calc(var(--chatbar-item-lead) * 0.6);
166
+ }
167
+
168
+ &:where([data-kind='image']) {
169
+ background: var(--gray-a3);
170
+ }
171
+ }
172
+
173
+ /* An attachment's file icon and spinner stay neutral */
174
+ .rt-ChatbarAttachment .rt-ChatbarItemLead:where([data-kind='icon']) {
131
175
  background: var(--gray-a3);
176
+ color: var(--gray-11);
132
177
  }
133
178
 
134
179
  .rt-ChatbarAttachmentImage {
180
+ width: 100%;
135
181
  height: 100%;
136
- width: auto;
137
- object-fit: contain;
182
+ object-fit: cover;
138
183
  max-width: none;
139
184
  }
140
185
 
141
- /* PaperclipIcon handles its own sizing via IconButton */
142
-
143
- .rt-ChatbarAttachment {
144
- position: relative;
145
- background-color: var(--gray-1);
146
- border-radius: var(--radius-1);
147
- }
148
-
149
- /* Maximum width for non-image attachments */
150
- .rt-ChatbarAttachment:where([data-kind='file']) {
151
- max-width: 200px;
186
+ .rt-ChatbarItemLabel {
187
+ min-width: 0;
152
188
  }
153
189
 
154
- .rt-ChatbarAttachmentRemove {
155
- position: absolute;
156
- top: var(--chatbar-remove-offset);
157
- right: var(--chatbar-remove-offset);
190
+ .rt-ChatbarItemRemove {
191
+ flex-shrink: 0;
192
+ margin-left: calc(var(--space-1) * -1);
158
193
  }
159
194
 
160
- /* Size-dependent radius for attachment container and preview */
161
195
  @breakpoints {
162
- .rt-ChatbarRoot:where(.rt-r-size-1) :where(.rt-ChatbarAttachment, .rt-ChatbarAttachmentPreview) {
163
- border-radius: var(--radius-1);
164
- /* border-radius: var(--radius-1); */
165
- }
166
- .rt-ChatbarRoot:where(.rt-r-size-2) :where(.rt-ChatbarAttachment, .rt-ChatbarAttachmentPreview) {
167
- border-radius: var(--radius-2);
168
- }
169
- .rt-ChatbarRoot:where(.rt-r-size-3) :where(.rt-ChatbarAttachment, .rt-ChatbarAttachmentPreview) {
170
- border-radius: var(--radius-3);
196
+ .rt-ChatbarRoot {
197
+ &:where(.rt-r-size-1) {
198
+ --chatbar-item-lead: var(--space-4);
199
+ --chatbar-item-lead-radius: var(--radius-1);
200
+ --chatbar-item-padding: var(--space-1);
201
+ }
202
+ &:where(.rt-r-size-2) {
203
+ --chatbar-item-lead: var(--space-5);
204
+ --chatbar-item-lead-radius: var(--radius-2);
205
+ --chatbar-item-padding: var(--space-1);
206
+ }
207
+ &:where(.rt-r-size-3) {
208
+ --chatbar-item-lead: calc(var(--space-5) + var(--space-1));
209
+ --chatbar-item-lead-radius: var(--radius-2);
210
+ --chatbar-item-padding: calc(var(--space-1) * 1.5);
211
+ }
171
212
  }
172
213
  }
173
214
 
@@ -575,24 +616,18 @@
575
616
  &:where(.rt-r-size-1) {
576
617
  font-size: var(--font-size-1);
577
618
  line-height: var(--line-height-1);
578
- --chatbar-attachment-thumb: var(--space-8);
579
- --chatbar-remove-offset: calc(var(--space-1) * 0.5);
580
619
  /* --chatbar-attachments-radius: min(var(--radius-5), calc(var(--radius-1) + var(--space-1))); */
581
620
  --chatbar-attachments-radius: var(--radius-1);
582
621
  }
583
622
  &:where(.rt-r-size-2) {
584
623
  font-size: var(--font-size-2);
585
624
  line-height: var(--line-height-2);
586
- --chatbar-attachment-thumb: var(--space-9);
587
- --chatbar-remove-offset: var(--space-1);
588
625
  /* --chatbar-attachments-radius: min(var(--radius-6), calc(var(--radius-2) + var(--space-2))); */
589
626
  --chatbar-attachments-radius: var(--radius-2);
590
627
  }
591
628
  &:where(.rt-r-size-3) {
592
629
  font-size: var(--font-size-3);
593
630
  line-height: var(--line-height-3);
594
- --chatbar-attachment-thumb: var(--space-9);
595
- --chatbar-remove-offset: var(--space-2);
596
631
  /* --chatbar-attachments-radius: min(var(--radius-7), calc(var(--radius-3) + var(--space-3))); */
597
632
  --chatbar-attachments-radius: var(--radius-3);
598
633
  }
@@ -1030,18 +1030,22 @@ InlineEnd.displayName = 'Chatbar.InlineEnd';
1030
1030
  /**
1031
1031
  * Renders a horizontally scrollable list of attachments above the inline row.
1032
1032
  * Hidden when empty unless `forceMount`. Override per-item with `renderAttachment`.
1033
+ * `Chatbar.Item`s passed as children follow the attachments in the same row.
1033
1034
  */
1034
1035
  interface AttachmentsRowProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children'> {
1035
1036
  asChild?: boolean;
1036
1037
  forceMount?: boolean;
1037
1038
  /** If provided, custom-render a tile; otherwise default tile is used */
1038
1039
  renderAttachment?: (attachment: ChatbarAttachment) => React.ReactNode;
1040
+ /** `Chatbar.Item`s: things the message refers to that are not files. */
1041
+ children?: React.ReactNode;
1039
1042
  }
1040
1043
 
1041
1044
  const AttachmentsRow = React.forwardRef<HTMLDivElement, AttachmentsRowProps>((props, forwardedRef) => {
1042
- const { asChild, forceMount, renderAttachment, className, ...divProps } = props;
1045
+ const { asChild, forceMount, renderAttachment, className, children, ...divProps } = props;
1043
1046
  const ctx = useChatbarContext();
1044
- if (ctx.attachments.length === 0 && !forceMount) return null;
1047
+ const hasItems = React.Children.toArray(children).length > 0;
1048
+ if (ctx.attachments.length === 0 && !hasItems && !forceMount) return null;
1045
1049
  const Comp = asChild ? Slot : 'div';
1046
1050
  return (
1047
1051
  <m.div layout="position" transition={LAYOUT_TRANSITION}>
@@ -1053,6 +1057,7 @@ const AttachmentsRow = React.forwardRef<HTMLDivElement, AttachmentsRowProps>((pr
1053
1057
  {renderAttachment?.(att)}
1054
1058
  </Attachment>
1055
1059
  ))}
1060
+ {children}
1056
1061
  </Flex>
1057
1062
  </ScrollArea>
1058
1063
  </Comp>
@@ -1061,6 +1066,50 @@ const AttachmentsRow = React.forwardRef<HTMLDivElement, AttachmentsRowProps>((pr
1061
1066
  });
1062
1067
  AttachmentsRow.displayName = 'Chatbar.AttachmentsRow';
1063
1068
 
1069
+ /**
1070
+ * Moves focus off a pill that is about to unmount, to the next pill's remove
1071
+ * button, the previous one's, or the text, so keyboard users are not dropped
1072
+ * to <body>.
1073
+ */
1074
+ function moveFocusOffPill(button: HTMLButtonElement, textareaRef: React.RefObject<HTMLTextAreaElement | null>) {
1075
+ const pill = button.closest('[role="listitem"]');
1076
+ const pills = pill?.parentElement ? Array.from(pill.parentElement.children) : [];
1077
+ const index = pill ? pills.indexOf(pill) : -1;
1078
+ const neighbour = pills[index + 1] ?? pills[index - 1];
1079
+ const nextButton = neighbour?.querySelector<HTMLButtonElement>('.rt-ChatbarItemRemove');
1080
+ if (nextButton) nextButton.focus();
1081
+ else textareaRef.current?.focus({ preventScroll: true });
1082
+ }
1083
+
1084
+ /** The pill an attachment and an item share: a lead tile, a label and ×. */
1085
+ const Pill: React.FC<{
1086
+ lead: React.ReactNode;
1087
+ leadKind: 'image' | 'icon';
1088
+ label: React.ReactNode;
1089
+ /** Read after the label by screen readers only, e.g. a file's size. */
1090
+ detail?: string;
1091
+ removeLabel: string;
1092
+ onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void;
1093
+ }> = ({ lead, leadKind, label, detail, removeLabel, onRemove }) => {
1094
+ const ctx = useChatbarContext();
1095
+ return (
1096
+ <>
1097
+ <span className="rt-ChatbarItemLead" data-kind={leadKind} aria-hidden>
1098
+ {lead}
1099
+ </span>
1100
+ <Text className="rt-ChatbarItemLabel" size={ctx.size} truncate>
1101
+ {label}
1102
+ {detail ? <VisuallyHidden>, {detail}</VisuallyHidden> : null}
1103
+ </Text>
1104
+ {onRemove && (
1105
+ <IconButton className="rt-ChatbarItemRemove" aria-label={removeLabel} size="1" variant="ghost" color="gray" onClick={onRemove}>
1106
+ <CloseIcon />
1107
+ </IconButton>
1108
+ )}
1109
+ </>
1110
+ );
1111
+ };
1112
+
1064
1113
  /** Default tile renderer for a single attachment. */
1065
1114
  interface AttachmentProps extends React.ComponentPropsWithoutRef<'div'> {
1066
1115
  attachment: ChatbarAttachment;
@@ -1079,71 +1128,98 @@ const Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>((props, for
1079
1128
  const locked = ctx.disabled || ctx.readOnly;
1080
1129
 
1081
1130
  const handleRemove = (event: React.MouseEvent<HTMLButtonElement>) => {
1082
- // Move focus before the tile unmounts so keyboard users are not dropped to <body>
1083
- const tile = event.currentTarget.closest('[role="listitem"]');
1084
- const tiles = tile?.parentElement ? Array.from(tile.parentElement.children) : [];
1085
- const index = tile ? tiles.indexOf(tile) : -1;
1086
- const neighbour = tiles[index + 1] ?? tiles[index - 1];
1087
- const nextButton = neighbour?.querySelector<HTMLButtonElement>('.rt-ChatbarAttachmentRemove');
1088
- if (nextButton) nextButton.focus();
1089
- else ctx.textareaRef.current?.focus({ preventScroll: true });
1131
+ moveFocusOffPill(event.currentTarget, ctx.textareaRef);
1090
1132
  ctx.removeAttachment(attachment.id);
1091
1133
  };
1092
1134
 
1135
+ const lead =
1136
+ attachment.status === 'uploading' ? (
1137
+ <Spinner size="1" />
1138
+ ) : isImage ? (
1139
+ <img className="rt-ChatbarAttachmentImage" src={attachment.url} alt="" />
1140
+ ) : (
1141
+ <FileTextIcon />
1142
+ );
1143
+
1093
1144
  return (
1094
1145
  <Comp
1095
1146
  {...divProps}
1096
1147
  ref={forwardedRef}
1097
- className={classNames('rt-ChatbarAttachment', className)}
1148
+ className={classNames(children ? 'rt-ChatbarAttachment' : 'rt-ChatbarAttachment rt-ChatbarItem', className)}
1098
1149
  role="listitem"
1099
1150
  data-kind={isImage ? 'image' : 'file'}
1100
1151
  data-status={attachment.status}
1101
1152
  title={attachment.name}
1102
1153
  >
1103
1154
  {children ?? (
1104
- <Flex align="center" gap="2" pr={!isImage ? '6' : undefined}>
1105
- <div className="rt-ChatbarAttachmentPreview" aria-hidden>
1106
- {isImage ? <img className="rt-ChatbarAttachmentImage" src={attachment.url} alt="" /> : <FileTextIcon />}
1107
- </div>
1108
- {isImage ? (
1109
- <VisuallyHidden>
1110
- {attachment.name}, {sizeLabel}
1111
- {statusLabel ? `, ${statusLabel}` : ''}
1112
- </VisuallyHidden>
1113
- ) : (
1114
- <Flex direction="column" gap="0" minWidth="0">
1115
- <Text size={ctx.size} weight="medium" truncate>
1116
- {attachment.name}
1117
- </Text>
1118
- <Text
1119
- size="1"
1120
- color={attachment.status === 'error' ? 'red' : undefined}
1121
- emphasis={attachment.status === 'error' ? undefined : 'medium'}
1122
- >
1123
- {sizeLabel}
1124
- {statusLabel ? ` · ${statusLabel}` : ''}
1125
- </Text>
1126
- </Flex>
1127
- )}
1128
- {!locked && (
1129
- <IconButton
1130
- className="rt-ChatbarAttachmentRemove"
1131
- aria-label={`Remove ${attachment.name}`}
1132
- size="1"
1133
- variant="classic"
1134
- color="gray"
1135
- onClick={handleRemove}
1136
- >
1137
- <CloseIcon />
1138
- </IconButton>
1139
- )}
1140
- </Flex>
1155
+ <Pill
1156
+ lead={lead}
1157
+ leadKind={isImage && attachment.status !== 'uploading' ? 'image' : 'icon'}
1158
+ label={attachment.name}
1159
+ detail={statusLabel ? `${sizeLabel}, ${statusLabel}` : sizeLabel}
1160
+ removeLabel={`Remove ${attachment.name}`}
1161
+ onRemove={locked ? undefined : handleRemove}
1162
+ />
1141
1163
  )}
1142
1164
  </Comp>
1143
1165
  );
1144
1166
  });
1145
1167
  Attachment.displayName = 'Chatbar.Attachment';
1146
1168
 
1169
+ /**
1170
+ * Something the message refers to that is not a file: a scene object, a
1171
+ * picked mode, a selection. The same pill as an attachment, placed in
1172
+ * `Chatbar.AttachmentsRow` as a child. The app owns the list, so removing one
1173
+ * calls `onRemove` and nothing else; leave it out for a pill that cannot be
1174
+ * removed.
1175
+ */
1176
+ interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'children' | 'color'> {
1177
+ /** The label. Also names the remove button: "Remove {label}". */
1178
+ children: string;
1179
+ /** Drawn in the lead tile, tinted with `color`. */
1180
+ icon?: React.ReactNode;
1181
+ /** A thumbnail for the lead tile, in place of the icon. */
1182
+ image?: string;
1183
+ /** Tint of the icon tile. Defaults to gray. */
1184
+ color?: (typeof accentColors)[number];
1185
+ onRemove?: () => void;
1186
+ /** Overrides the remove button's label. */
1187
+ removeLabel?: string;
1188
+ }
1189
+
1190
+ const Item = React.forwardRef<HTMLDivElement, ItemProps>((props, forwardedRef) => {
1191
+ const { children, icon, image, color, onRemove, removeLabel, className, ...divProps } = props;
1192
+ const ctx = useChatbarContext();
1193
+ const locked = ctx.disabled || ctx.readOnly;
1194
+
1195
+ const handleRemove = onRemove
1196
+ ? (event: React.MouseEvent<HTMLButtonElement>) => {
1197
+ moveFocusOffPill(event.currentTarget, ctx.textareaRef);
1198
+ onRemove();
1199
+ }
1200
+ : undefined;
1201
+
1202
+ return (
1203
+ <div
1204
+ {...divProps}
1205
+ ref={forwardedRef}
1206
+ className={classNames('rt-ChatbarItem', className)}
1207
+ role="listitem"
1208
+ data-accent-color={image ? undefined : (color ?? 'gray')}
1209
+ title={children}
1210
+ >
1211
+ <Pill
1212
+ lead={image ? <img className="rt-ChatbarAttachmentImage" src={image} alt="" /> : icon}
1213
+ leadKind={image ? 'image' : 'icon'}
1214
+ label={children}
1215
+ removeLabel={removeLabel ?? `Remove ${children}`}
1216
+ onRemove={locked ? undefined : handleRemove}
1217
+ />
1218
+ </div>
1219
+ );
1220
+ });
1221
+ Item.displayName = 'Chatbar.Item';
1222
+
1147
1223
  interface AttachTriggerProps extends React.ComponentPropsWithoutRef<'button'> {
1148
1224
  asChild?: boolean;
1149
1225
  /** Overrides Root `accept` for files chosen through this trigger */
@@ -1346,13 +1422,14 @@ const Send = React.forwardRef<HTMLButtonElement, SendProps>((props, forwardedRef
1346
1422
  });
1347
1423
  Send.displayName = 'Chatbar.Send';
1348
1424
 
1349
- export { Root, Textarea, InlineStart, InlineEnd, AttachmentsRow, Attachment, AttachTrigger, Row, RowStart, RowEnd, Send };
1425
+ export { Root, Textarea, InlineStart, InlineEnd, AttachmentsRow, Attachment, Item, AttachTrigger, Row, RowStart, RowEnd, Send };
1350
1426
  export type {
1351
1427
  RootProps as ChatbarRootProps,
1352
1428
  TextareaProps as ChatbarTextareaProps,
1353
1429
  InlineSlotProps as ChatbarInlineSlotProps,
1354
1430
  AttachmentsRowProps as ChatbarAttachmentsRowProps,
1355
1431
  AttachmentProps as ChatbarAttachmentProps,
1432
+ ItemProps as ChatbarItemProps,
1356
1433
  AttachTriggerProps as ChatbarAttachTriggerProps,
1357
1434
  RowProps as ChatbarRowProps,
1358
1435
  RowSlotProps as ChatbarRowSlotProps,