@konce-pt/react 0.8.3 → 0.8.5
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.
- package/README.md +4 -4
- package/dist/backdrop/backdrop.d.ts +79 -0
- package/dist/backdrop/backdrop.js +195 -0
- package/dist/backdrop/llms.txt +132 -0
- package/dist/chat/chat.d.ts +53 -0
- package/dist/chat/chat.js +160 -0
- package/dist/chat/llms.txt +78 -0
- package/dist/chat-composer/chat-composer.d.ts +41 -0
- package/dist/chat-composer/chat-composer.js +81 -0
- package/dist/chat-composer/llms.txt +56 -0
- package/dist/chat-dock/chat-dock.d.ts +40 -0
- package/dist/chat-dock/chat-dock.js +67 -0
- package/dist/chat-dock/llms.txt +71 -0
- package/dist/chat-entry/public-api.d.ts +12 -0
- package/dist/chat-entry/public-api.js +10 -0
- package/dist/chat-message/chat-message.d.ts +53 -0
- package/dist/chat-message/chat-message.js +112 -0
- package/dist/chat-message/llms.txt +73 -0
- package/dist/chat-typing/chat-typing.d.ts +23 -0
- package/dist/chat-typing/chat-typing.js +30 -0
- package/dist/chat-typing/llms.txt +41 -0
- package/dist/chip/chip.d.ts +10 -1
- package/dist/chip/chip.js +3 -2
- package/dist/chip/llms.txt +9 -0
- package/dist/data-view/data-view.d.ts +15 -3
- package/dist/data-view/data-view.js +22 -5
- package/dist/data-view/llms.txt +20 -7
- package/dist/internal/dev.d.ts +1 -0
- package/dist/internal/dev.js +3 -0
- package/dist/internal/overlay.d.ts +6 -1
- package/dist/internal/overlay.js +5 -3
- package/dist/mention/llms.txt +60 -0
- package/dist/mention/mention.d.ts +38 -0
- package/dist/mention/mention.js +29 -0
- package/dist/mention/mentions.d.ts +31 -0
- package/dist/mention/mentions.js +24 -0
- package/dist/order-list/llms.txt +16 -5
- package/dist/order-list/order-list.d.ts +5 -3
- package/dist/order-list/order-list.js +69 -4
- package/dist/peek/llms.txt +42 -0
- package/dist/peek/peek.d.ts +35 -0
- package/dist/peek/peek.js +127 -0
- package/dist/pick-list/llms.txt +19 -5
- package/dist/pick-list/pick-list.d.ts +7 -2
- package/dist/pick-list/pick-list.js +123 -17
- package/dist/public-api.d.ts +13 -1
- package/dist/public-api.js +6 -0
- package/dist/rich-text/llms.txt +10 -0
- package/dist/rich-text/rich-text.d.ts +11 -1
- package/dist/rich-text/rich-text.js +157 -5
- package/dist/rich-text/sanitize.js +10 -1
- package/dist/roadmap/roadmap.js +1 -3
- package/package.json +16 -9
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# KptChat (@konce-pt/react/chat)
|
|
2
|
+
|
|
3
|
+
A conversation list: grouped messages, day separators, an unread divider, the view pinned to the
|
|
4
|
+
newest message and an offline strip counting what still has to go out.
|
|
5
|
+
Import: `import { KptChat } from '@konce-pt/react/chat';`
|
|
6
|
+
|
|
7
|
+
## DOM
|
|
8
|
+
<div class="kpt-chat-host">
|
|
9
|
+
<p class="kpt-chat__offline" role="status">…</p>
|
|
10
|
+
<div class="kpt-chat__body">
|
|
11
|
+
<div class="kpt-chat__viewport" role="log" aria-live="polite">
|
|
12
|
+
<p class="kpt-chat__date" role="separator">Today</p>
|
|
13
|
+
<div class="kpt-chat-message-host">…</div>
|
|
14
|
+
<p class="kpt-chat__unread" role="separator">…</p>
|
|
15
|
+
<div class="kpt-chat-typing-host">…</div>
|
|
16
|
+
</div>
|
|
17
|
+
<p class="kpt-chat__day" aria-hidden="true">Today</p>
|
|
18
|
+
<button class="kpt-chat__jump">3 new messages</button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
## Props
|
|
23
|
+
- `messages`: `KptChatMessage<T>[]` — **in chronological order**; the component does not sort
|
|
24
|
+
- `currentUser`: string — the author id treated as "me"
|
|
25
|
+
- `groupGapMs`: number (default `KPT_CHAT_GROUP_GAP_MS`, 5 min)
|
|
26
|
+
- `unreadFrom`: string | null — the id of the first unread message
|
|
27
|
+
- `offline`: boolean, `typing`: `KptChatAuthor[]`, `autoScroll`: boolean (default true)
|
|
28
|
+
- `actions`: boolean — reply and react buttons on hover; `emptyLabel`: string
|
|
29
|
+
- `reactionOptions`: `readonly KptChatReactionOption[]` (default `KPT_CHAT_REACTIONS`) — passed to the bubbles
|
|
30
|
+
- `onRetry` / `onReply` — `(message) => void`; `onReact` — `(event: KptChatReactEvent) => void`
|
|
31
|
+
(`{ message, emoji }`); `onQuoted` — `(quote) => void`
|
|
32
|
+
- `onLoadMore` — fired once when the viewport reaches the top, not on every scroll event
|
|
33
|
+
- `renderMessage(message, row)` — the equivalent of `<ng-template let-message>`; it replaces the
|
|
34
|
+
bubble's text, and the bubble, its grouping and its meta row stay in place
|
|
35
|
+
- `className`, `ref` and every other `<div>` prop go to the host
|
|
36
|
+
|
|
37
|
+
## The composer is a sibling, not a slot
|
|
38
|
+
A read-only conversation (an archive, a preview) should not have to carry a text field it never
|
|
39
|
+
shows, so `KptChatComposer` is placed next to the list rather than inside it. The parent is what
|
|
40
|
+
gives the list its height — `.kpt-chat-host` is a column flexbox with `min-height: 0`.
|
|
41
|
+
|
|
42
|
+
## Transport stays in the application
|
|
43
|
+
The component takes messages and only reports. Retrying a send is a status change the application
|
|
44
|
+
makes; `mergeStatus()` from `@konce-pt/chat` is there for out-of-order acks.
|
|
45
|
+
|
|
46
|
+
## Bubble actions: reply and react
|
|
47
|
+
`actions` puts two buttons beside the bubble. The arrow emits `onReply` with the message —
|
|
48
|
+
`replyRefFrom()` from `@konce-pt/chat` turns it into the `replyTo` the composer's quote bar wants.
|
|
49
|
+
The smiley opens a picker of five reactions (`KPT_CHAT_REACTIONS`) and emits `onReact` with the
|
|
50
|
+
chosen emoji; `toggleReaction(message.reactions, emoji)` is the arithmetic that puts it in the
|
|
51
|
+
model. The library writes nothing itself.
|
|
52
|
+
|
|
53
|
+
## One day pill, not a stack of them
|
|
54
|
+
The separators in the list travel with the content; which day the view is standing in is told by one
|
|
55
|
+
pill floating over the list (`.kpt-chat__day`, `aria-hidden`). On every scroll (and after every
|
|
56
|
+
re-render and resize) the component measures the separators, the unread divider and the system
|
|
57
|
+
notices, hands the rectangles to `chatDayIndicator()` from `@konce-pt/chat` and swaps the pill's
|
|
58
|
+
text; a centred label driving into the pill's band puts the pill out, never the content.
|
|
59
|
+
|
|
60
|
+
## Examples
|
|
61
|
+
<KptChat messages={messages} currentUser="me" typing={typing} onRetry={resend} />
|
|
62
|
+
<KptChat messages={messages} currentUser="me" offline unreadFrom="m-42" onLoadMore={loadOlder} />
|
|
63
|
+
<KptChat messages={messages} currentUser="me" renderMessage={(m) => <KptCard>{m.data.title}</KptCard>} />
|
|
64
|
+
|
|
65
|
+
## i18n
|
|
66
|
+
`today`, `yesterday`, `unread`, `newMessages` (`{count}`), `empty`, `offline`, `pending`
|
|
67
|
+
(`{count}`) from the `chat` namespace. Weekday names and full dates come from `Intl`, not the
|
|
68
|
+
dictionary.
|
|
69
|
+
|
|
70
|
+
## Tokens
|
|
71
|
+
The offline strip `--kpt-color-warning-subtle`, the day separator pill `--kpt-color-surface-raised`
|
|
72
|
+
+ `--kpt-elevation-1`, the jump
|
|
73
|
+
pill `--kpt-color-primary-border` + `--kpt-elevation-2`.
|
|
74
|
+
|
|
75
|
+
## Accessibility
|
|
76
|
+
`role="log"` with `aria-live="polite"` announces a new message without cutting off what is being
|
|
77
|
+
read. Separators are `role="separator"` with a label. Auto-scroll uses `behavior: 'auto'` under
|
|
78
|
+
`prefers-reduced-motion`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef } from 'react';
|
|
2
|
+
import type { KptChatReplyRef } from '@konce-pt/chat';
|
|
3
|
+
import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
|
|
4
|
+
/** Ładunek wysyłki. Kompozytor niczego nie wysyła — pakuje dane i oddaje aplikacji. */
|
|
5
|
+
export interface KptChatComposerSubmit {
|
|
6
|
+
text: string;
|
|
7
|
+
files: readonly File[];
|
|
8
|
+
replyTo: KptChatReplyRef | null;
|
|
9
|
+
}
|
|
10
|
+
export interface KptChatComposerProps extends Omit<ComponentPropsWithRef<'div'>, 'children' | 'onSubmit'>, KptI18nOverrides {
|
|
11
|
+
value?: string;
|
|
12
|
+
defaultValue?: string;
|
|
13
|
+
onValueChange?: (value: string) => void;
|
|
14
|
+
/** Podpowiedź w pustym polu; bez niej etykieta ze słownika. */
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** Brak połączenia — przycisk mówi o kolejkowaniu, nie o wysyłce. */
|
|
18
|
+
offline?: boolean;
|
|
19
|
+
allowAttachments?: boolean;
|
|
20
|
+
/** Ile wierszy pole może urosnąć, zanim zacznie się przewijać. */
|
|
21
|
+
maxRows?: number;
|
|
22
|
+
enterToSend?: boolean;
|
|
23
|
+
/** Wiadomość, na którą odpowiadamy. Po wysłaniu czyści się razem z treścią. */
|
|
24
|
+
replyTo?: KptChatReplyRef | null;
|
|
25
|
+
defaultReplyTo?: KptChatReplyRef | null;
|
|
26
|
+
onReplyToChange?: (replyTo: KptChatReplyRef | null) => void;
|
|
27
|
+
onSend?: (payload: KptChatComposerSubmit) => void;
|
|
28
|
+
/** Uderzenie w klawiaturę — sygnał „piszę" dla drugiej strony. */
|
|
29
|
+
onTyping?: () => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Pole pisania wiadomości — autorosnące, z załącznikami i paskiem cytatu.
|
|
33
|
+
*
|
|
34
|
+
* `Enter` wysyła, `Shift+Enter` łamie linię (`enterToSend={false}` odwraca układ).
|
|
35
|
+
* Przy `offline` przycisk zmienia etykietę na kolejkowanie — wiadomość dalej da się napisać
|
|
36
|
+
* i oddać aplikacji, bo to ona decyduje, co zrobić z kolejką.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* <KptChatComposer allowAttachments onSend={(payload) => send(payload)} />
|
|
40
|
+
*/
|
|
41
|
+
export declare function KptChatComposer({ value, defaultValue, onValueChange, placeholder, disabled, offline, allowAttachments, maxRows, enterToSend, replyTo, defaultReplyTo, onReplyToChange, onSend, onTyping, locale, dictionary, className, ...rest }: Readonly<KptChatComposerProps>): import("react").JSX.Element;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState, } from 'react';
|
|
3
|
+
import { cn } from "../internal/cn.js";
|
|
4
|
+
import { useControllableState } from "../internal/use-controllable-state.js";
|
|
5
|
+
import { useKptMessages } from "../i18n/i18n-context.js";
|
|
6
|
+
import { KptIcon } from "../icon/icon.js";
|
|
7
|
+
import { KptIconButton } from "../icon-button/icon-button.js";
|
|
8
|
+
/**
|
|
9
|
+
* Pole pisania wiadomości — autorosnące, z załącznikami i paskiem cytatu.
|
|
10
|
+
*
|
|
11
|
+
* `Enter` wysyła, `Shift+Enter` łamie linię (`enterToSend={false}` odwraca układ).
|
|
12
|
+
* Przy `offline` przycisk zmienia etykietę na kolejkowanie — wiadomość dalej da się napisać
|
|
13
|
+
* i oddać aplikacji, bo to ona decyduje, co zrobić z kolejką.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <KptChatComposer allowAttachments onSend={(payload) => send(payload)} />
|
|
17
|
+
*/
|
|
18
|
+
export function KptChatComposer({ value, defaultValue = '', onValueChange, placeholder = '', disabled = false, offline = false, allowAttachments = false, maxRows = 6, enterToSend = true, replyTo, defaultReplyTo = null, onReplyToChange, onSend, onTyping, locale, dictionary, className, ...rest }) {
|
|
19
|
+
const { t } = useKptMessages({ locale, dictionary });
|
|
20
|
+
const [text, setText] = useControllableState({
|
|
21
|
+
value,
|
|
22
|
+
defaultValue,
|
|
23
|
+
onChange: onValueChange,
|
|
24
|
+
});
|
|
25
|
+
const [quote, setQuote] = useControllableState({
|
|
26
|
+
value: replyTo,
|
|
27
|
+
defaultValue: defaultReplyTo,
|
|
28
|
+
onChange: onReplyToChange,
|
|
29
|
+
});
|
|
30
|
+
const [files, setFiles] = useState([]);
|
|
31
|
+
const fieldRef = useRef(null);
|
|
32
|
+
const pickerRef = useRef(null);
|
|
33
|
+
const canSend = !disabled && (text.trim().length > 0 || files.length > 0);
|
|
34
|
+
const sendLabel = offline ? t('chat.queue') : t('chat.send');
|
|
35
|
+
/**
|
|
36
|
+
* Wysokość liczona od treści. Zerowanie przed odczytem `scrollHeight` jest konieczne:
|
|
37
|
+
* bez niego pole potrafi tylko rosnąć, bo `scrollHeight` nigdy nie zejdzie poniżej
|
|
38
|
+
* ustawionej wcześniej wysokości. Górny limit trzyma CSS (`--_max-rows`).
|
|
39
|
+
*/
|
|
40
|
+
const grow = (element) => {
|
|
41
|
+
element.style.height = 'auto';
|
|
42
|
+
// `scrollHeight` liczy treść z paddingiem, ale BEZ ramki, a pole jest `border-box`.
|
|
43
|
+
// Sama wysokość ze `scrollHeight` zostawia je o grubość ramki za niskie, więc puste
|
|
44
|
+
// pole na jeden wiersz dostaje pasek przewijania i nigdy się go nie pozbywa.
|
|
45
|
+
const borders = element.offsetHeight - element.clientHeight;
|
|
46
|
+
element.style.height = element.scrollHeight + borders + 'px';
|
|
47
|
+
};
|
|
48
|
+
const submit = () => {
|
|
49
|
+
if (!canSend)
|
|
50
|
+
return;
|
|
51
|
+
onSend?.({ text: text.trim(), files, replyTo: quote });
|
|
52
|
+
setText('');
|
|
53
|
+
setFiles([]);
|
|
54
|
+
setQuote(null);
|
|
55
|
+
const field = fieldRef.current;
|
|
56
|
+
if (field) {
|
|
57
|
+
field.value = '';
|
|
58
|
+
grow(field);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const onKeyDown = (event) => {
|
|
62
|
+
if (event.key !== 'Enter')
|
|
63
|
+
return;
|
|
64
|
+
const wantsSend = enterToSend ? !event.shiftKey : event.shiftKey;
|
|
65
|
+
if (!wantsSend || event.nativeEvent.isComposing)
|
|
66
|
+
return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
submit();
|
|
69
|
+
};
|
|
70
|
+
return (_jsxs("div", { className: cn('kpt-chat-composer-host', className), ...rest, children: [quote ? (_jsxs("div", { className: "kpt-chat-composer__reply", children: [_jsxs("span", { className: "kpt-chat-composer__reply-body", children: [_jsx("span", { className: "kpt-chat-composer__reply-author", children: t('chat.replyTo', { author: quote.author }) }), _jsx("span", { className: "kpt-chat-composer__reply-text", children: quote.excerpt })] }), _jsx(KptIconButton, { size: "sm", "aria-label": t('chat.cancelReply'), onClick: () => setQuote(null), children: _jsx(KptIcon, { name: "x" }) })] })) : null, files.length ? (_jsx("ul", { className: "kpt-chat-composer__files", children: files.map((file) => (_jsxs("li", { className: "kpt-chat-composer__file", children: [_jsx(KptIcon, { name: "paperclip" }), _jsx("span", { className: "kpt-chat-composer__file-name", children: file.name }), _jsx(KptIconButton, { size: "sm", "aria-label": t('chat.removeAttachment'), onClick: () => setFiles((current) => current.filter((item) => item !== file)), children: _jsx(KptIcon, { name: "x" }) })] }, file.name + file.size + file.lastModified))) })) : null, _jsxs("div", { className: "kpt-chat-composer", children: [allowAttachments ? (_jsxs(_Fragment, { children: [_jsx("input", { ref: pickerRef, type: "file", className: "kpt-chat-composer__picker", multiple: true, onChange: (event) => {
|
|
71
|
+
const picked = Array.from(event.currentTarget.files ?? []);
|
|
72
|
+
if (picked.length)
|
|
73
|
+
setFiles((current) => [...current, ...picked]);
|
|
74
|
+
// Bez wyzerowania ten sam plik wybrany drugi raz nie wywoła `change`.
|
|
75
|
+
event.currentTarget.value = '';
|
|
76
|
+
} }), _jsx(KptIconButton, { size: "sm", disabled: disabled, "aria-label": t('chat.attach'), onClick: () => pickerRef.current?.click(), children: _jsx(KptIcon, { name: "paperclip" }) })] })) : null, _jsx("textarea", { ref: fieldRef, className: "kpt-chat-composer__field", rows: 1, style: { '--_max-rows': maxRows }, value: text, disabled: disabled, placeholder: placeholder || t('chat.placeholder'), "aria-label": placeholder || t('chat.placeholder'), onChange: (event) => {
|
|
77
|
+
setText(event.currentTarget.value);
|
|
78
|
+
grow(event.currentTarget);
|
|
79
|
+
onTyping?.();
|
|
80
|
+
}, onKeyDown: onKeyDown }), _jsx(KptIconButton, { variant: "filled", disabled: !canSend, "aria-label": sendLabel, onClick: submit, children: _jsx(KptIcon, { name: offline ? 'clock' : 'send' }) })] })] }));
|
|
81
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# KptChatComposer (@konce-pt/react/chat)
|
|
2
|
+
|
|
3
|
+
The message field — auto-growing, with attachments and a quote strip.
|
|
4
|
+
Import: `import { KptChatComposer } from '@konce-pt/react/chat';`
|
|
5
|
+
|
|
6
|
+
## DOM
|
|
7
|
+
<div class="kpt-chat-composer-host">
|
|
8
|
+
<div class="kpt-chat-composer__reply">…the quoted message…</div>
|
|
9
|
+
<ul class="kpt-chat-composer__files">…picked files…</ul>
|
|
10
|
+
<div class="kpt-chat-composer">
|
|
11
|
+
<input type="file" class="kpt-chat-composer__picker" hidden />
|
|
12
|
+
<!-- KptIconButton: attach -->
|
|
13
|
+
<textarea class="kpt-chat-composer__field" rows="1"></textarea>
|
|
14
|
+
<!-- KptIconButton: send -->
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
## Props
|
|
19
|
+
- `value` / `defaultValue` / `onValueChange` — the text
|
|
20
|
+
- `replyTo` / `defaultReplyTo` / `onReplyToChange` — `KptChatReplyRef | null`
|
|
21
|
+
- `placeholder`, `disabled`, `offline`, `allowAttachments`
|
|
22
|
+
- `maxRows`: number (default 6), `enterToSend`: boolean (default true)
|
|
23
|
+
- `onSend(payload)` — `{ text, files, replyTo }`; the text is trimmed
|
|
24
|
+
- `onTyping()` — every keystroke, for the "is typing" signal
|
|
25
|
+
- `className`, `ref` and every other `<div>` prop go to the host
|
|
26
|
+
|
|
27
|
+
## Enter sends, Shift+Enter breaks the line
|
|
28
|
+
`enterToSend={false}` swaps the two. A keystroke arriving mid-composition (an IME) never sends —
|
|
29
|
+
`isComposing` is checked, otherwise picking a candidate would fire the message.
|
|
30
|
+
|
|
31
|
+
## The height comes from the content
|
|
32
|
+
The field grows with the text and then scrolls. The script sets `height` to `scrollHeight`, having
|
|
33
|
+
zeroed it first — without that reset the field can only grow, because `scrollHeight` never drops
|
|
34
|
+
below the height already set. The ceiling is CSS: `--_max-rows`.
|
|
35
|
+
|
|
36
|
+
## Offline does not lock the field
|
|
37
|
+
The button changes its label to queueing and the payload still reaches `onSend`. What to do with a
|
|
38
|
+
queue is the application's decision — the component has no transport to hold it back with.
|
|
39
|
+
|
|
40
|
+
## Examples
|
|
41
|
+
<KptChatComposer allowAttachments onSend={send} />
|
|
42
|
+
<KptChatComposer value={draft} onValueChange={setDraft} offline={!online} onSend={queue} />
|
|
43
|
+
<KptChatComposer replyTo={quote} onReplyToChange={setQuote} enterToSend={false} onSend={send} />
|
|
44
|
+
|
|
45
|
+
## i18n
|
|
46
|
+
`placeholder`, `send`, `queue`, `attach`, `removeAttachment`, `replyTo` (`{author}`),
|
|
47
|
+
`cancelReply` from the `chat` namespace.
|
|
48
|
+
|
|
49
|
+
## Tokens
|
|
50
|
+
The field `--kpt-color-surface-variant` + `--kpt-radius-lg`, the top edge `--kpt-color-border`,
|
|
51
|
+
the quote strip `--kpt-color-primary`.
|
|
52
|
+
|
|
53
|
+
## Accessibility
|
|
54
|
+
The textarea carries an `aria-label` equal to the placeholder — a field without a label is
|
|
55
|
+
invisible to a screen reader. Both icon buttons are labelled from the dictionary. It is not a
|
|
56
|
+
form control: no `touched`/`invalid` state, and the value disappears on send.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, type ReactNode } from 'react';
|
|
2
|
+
import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
|
|
3
|
+
export type KptChatDockPosition = 'bottom-end' | 'bottom-start';
|
|
4
|
+
export interface KptChatDockProps extends Omit<ComponentPropsWithRef<'div'>, 'children'>, KptI18nOverrides {
|
|
5
|
+
open?: boolean;
|
|
6
|
+
defaultOpen?: boolean;
|
|
7
|
+
onOpenChange?: (open: boolean) => void;
|
|
8
|
+
position?: KptChatDockPosition;
|
|
9
|
+
/** Licznik na plakietce FAB-a. Zero chowa plakietkę. */
|
|
10
|
+
unread?: number;
|
|
11
|
+
header?: string;
|
|
12
|
+
/** Ikona zamkniętego FAB-a; otwarty zawsze pokazuje krzyżyk. */
|
|
13
|
+
icon?: string;
|
|
14
|
+
/** Szerokość panelu na ekranach powyżej `--kpt-breakpoint-sm`. */
|
|
15
|
+
width?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Odsunięcie od rogu ekranu (dowolna długość CSS), wspólne dla obu osi.
|
|
18
|
+
* Domyślnie `--kpt-space-6`. Przydaje się, gdy w tym samym rogu stoi już inny widget —
|
|
19
|
+
* `KptScrollTop` przykleja się dokładnie tam samo i bez tego oba się nakładają.
|
|
20
|
+
*/
|
|
21
|
+
offset?: string;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Pływający czat w rogu ekranu — FAB z licznikiem nieprzeczytanych i panel z rozmową.
|
|
26
|
+
*
|
|
27
|
+
* Zawartość panelu jest dzieckiem, więc dock nie wie nic o wiadomościach: wkłada się do niego
|
|
28
|
+
* `KptChat` i `KptChatComposer` (albo cokolwiek innego). Sam FAB to `KptFab` — dock go używa,
|
|
29
|
+
* nie zastępuje.
|
|
30
|
+
*
|
|
31
|
+
* Poniżej `--kpt-breakpoint-sm` panel wchodzi na pełny ekran: 22,5 rem szerokości na telefonie
|
|
32
|
+
* to nie panel, tylko szpara.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <KptChatDock unread={3} header="Wsparcie">
|
|
36
|
+
* <KptChat messages={messages} currentUser="me" />
|
|
37
|
+
* <KptChatComposer onSend={send} />
|
|
38
|
+
* </KptChatDock>
|
|
39
|
+
*/
|
|
40
|
+
export declare function KptChatDock({ open, defaultOpen, onOpenChange, position, unread, header, icon, width, offset, locale, dictionary, className, children, ...rest }: Readonly<KptChatDockProps>): import("react").JSX.Element;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, } from 'react';
|
|
3
|
+
import { cn } from "../internal/cn.js";
|
|
4
|
+
import { useControllableState } from "../internal/use-controllable-state.js";
|
|
5
|
+
import { useKptMessages } from "../i18n/i18n-context.js";
|
|
6
|
+
import { KptBadge } from "../badge/badge.js";
|
|
7
|
+
import { KptFab } from "../fab/fab.js";
|
|
8
|
+
import { KptIcon } from "../icon/icon.js";
|
|
9
|
+
import { KptIconButton } from "../icon-button/icon-button.js";
|
|
10
|
+
/**
|
|
11
|
+
* Pływający czat w rogu ekranu — FAB z licznikiem nieprzeczytanych i panel z rozmową.
|
|
12
|
+
*
|
|
13
|
+
* Zawartość panelu jest dzieckiem, więc dock nie wie nic o wiadomościach: wkłada się do niego
|
|
14
|
+
* `KptChat` i `KptChatComposer` (albo cokolwiek innego). Sam FAB to `KptFab` — dock go używa,
|
|
15
|
+
* nie zastępuje.
|
|
16
|
+
*
|
|
17
|
+
* Poniżej `--kpt-breakpoint-sm` panel wchodzi na pełny ekran: 22,5 rem szerokości na telefonie
|
|
18
|
+
* to nie panel, tylko szpara.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* <KptChatDock unread={3} header="Wsparcie">
|
|
22
|
+
* <KptChat messages={messages} currentUser="me" />
|
|
23
|
+
* <KptChatComposer onSend={send} />
|
|
24
|
+
* </KptChatDock>
|
|
25
|
+
*/
|
|
26
|
+
export function KptChatDock({ open, defaultOpen = false, onOpenChange, position = 'bottom-end', unread = 0, header = '', icon = 'message-circle', width = '22.5rem', offset = '', locale, dictionary, className, children, ...rest }) {
|
|
27
|
+
const { t } = useKptMessages({ locale, dictionary });
|
|
28
|
+
const [isOpen, setOpen] = useControllableState({
|
|
29
|
+
value: open,
|
|
30
|
+
defaultValue: defaultOpen,
|
|
31
|
+
onChange: onOpenChange,
|
|
32
|
+
});
|
|
33
|
+
const panelRef = useRef(null);
|
|
34
|
+
const triggerRef = useRef(null);
|
|
35
|
+
/*
|
|
36
|
+
* Fokus ląduje na polu pisania, jeśli w panelu jest — po to się czat otwiera.
|
|
37
|
+
* Bez pola bierze pierwszy element interaktywny, a w ostateczności sam panel.
|
|
38
|
+
*/
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!isOpen)
|
|
41
|
+
return;
|
|
42
|
+
const element = panelRef.current;
|
|
43
|
+
if (!element)
|
|
44
|
+
return;
|
|
45
|
+
const frame = requestAnimationFrame(() => {
|
|
46
|
+
const target = element.querySelector('textarea:not([disabled])') ??
|
|
47
|
+
element.querySelector('input:not([disabled]), button:not([disabled])') ??
|
|
48
|
+
element;
|
|
49
|
+
target.focus();
|
|
50
|
+
});
|
|
51
|
+
return () => cancelAnimationFrame(frame);
|
|
52
|
+
}, [isOpen]);
|
|
53
|
+
const close = () => {
|
|
54
|
+
if (!isOpen)
|
|
55
|
+
return;
|
|
56
|
+
setOpen(false);
|
|
57
|
+
// Fokus wraca na FAB — po zamknięciu panelu nie może zostać na elemencie,
|
|
58
|
+
// który przed chwilą zniknął z drzewa dostępności.
|
|
59
|
+
triggerRef.current?.querySelector('button')?.focus();
|
|
60
|
+
};
|
|
61
|
+
const onKeyDown = (event) => {
|
|
62
|
+
if (event.key === 'Escape')
|
|
63
|
+
close();
|
|
64
|
+
};
|
|
65
|
+
const fab = (_jsx(KptFab, { "aria-label": isOpen ? t('chat.close') : t('chat.open'), onClick: () => setOpen(!isOpen), children: _jsx(KptIcon, { name: isOpen ? 'x' : icon }) }));
|
|
66
|
+
return (_jsxs("div", { className: cn('kpt-chat-dock-host', className), onKeyDown: onKeyDown, ...rest, children: [_jsxs("div", { ref: panelRef, className: cn('kpt-chat-dock__panel', isOpen && 'is-open'), "data-position": position, role: "dialog", tabIndex: -1, style: { '--_width': width, ...(offset ? { '--_offset': offset } : {}) }, "aria-label": header || t('chat.open'), "aria-hidden": isOpen ? undefined : true, inert: !isOpen, children: [_jsxs("header", { className: "kpt-chat-dock__header", children: [_jsx("span", { className: "kpt-chat-dock__title", children: header }), _jsx(KptIconButton, { size: "sm", "aria-label": t('chat.close'), onClick: close, children: _jsx(KptIcon, { name: "x" }) })] }), _jsx("div", { className: "kpt-chat-dock__content", children: children })] }), _jsx("span", { ref: triggerRef, className: "kpt-chat-dock__trigger", "data-position": position, style: offset ? { '--_offset': offset } : undefined, children: unread > 0 ? (_jsx(KptBadge, { value: unread, variant: "danger", size: "sm", overlay: true, "aria-label": t('chat.unreadBadge', { count: unread }), children: fab })) : (fab) })] }));
|
|
67
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# KptChatDock (@konce-pt/react/chat)
|
|
2
|
+
|
|
3
|
+
A floating chat in the corner of the screen — a FAB with an unread badge plus a panel holding the
|
|
4
|
+
conversation.
|
|
5
|
+
Import: `import { KptChatDock } from '@konce-pt/react/chat';`
|
|
6
|
+
|
|
7
|
+
## DOM
|
|
8
|
+
<div class="kpt-chat-dock-host">
|
|
9
|
+
<div class="kpt-chat-dock__panel is-open" role="dialog" data-position="bottom-end">
|
|
10
|
+
<header class="kpt-chat-dock__header">
|
|
11
|
+
<span class="kpt-chat-dock__title">Support</span>
|
|
12
|
+
<!-- KptIconButton: close -->
|
|
13
|
+
</header>
|
|
14
|
+
<div class="kpt-chat-dock__content">…children…</div>
|
|
15
|
+
</div>
|
|
16
|
+
<span class="kpt-chat-dock__trigger" data-position="bottom-end">
|
|
17
|
+
<!-- KptBadge overlay → KptFab -->
|
|
18
|
+
</span>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
## Props
|
|
22
|
+
- `open` / `defaultOpen` / `onOpenChange` — works controlled and uncontrolled
|
|
23
|
+
- `position`: `'bottom-end' | 'bottom-start'` (logical, so RTL flips the corner on its own)
|
|
24
|
+
- `unread`: number — 0 hides the badge; `header`: string
|
|
25
|
+
- `icon`: string (default `message-circle`) — the closed FAB; open always shows a cross
|
|
26
|
+
- `width`: string (default `22.5rem`) — above `--kpt-breakpoint-sm`
|
|
27
|
+
- `offset`: string — distance from the corner, both axes (default `--kpt-space-6`)
|
|
28
|
+
- `children` — the panel content
|
|
29
|
+
- `className`, `ref` and every other `<div>` prop go to the host
|
|
30
|
+
|
|
31
|
+
## The FAB is KptFab, not a copy of it
|
|
32
|
+
The dock composes the existing components — `KptFab`, `KptBadge`, `KptIconButton` — so a change to
|
|
33
|
+
the FAB reaches the chat as well. The content is `children`, which means the dock knows nothing
|
|
34
|
+
about messages: put `KptChat` and `KptChatComposer` inside, or anything else.
|
|
35
|
+
|
|
36
|
+
## Sharing the corner with KptScrollTop
|
|
37
|
+
`KptScrollTop` pins itself to the same spot with the same `--kpt-z-overlay`, and it would swallow the
|
|
38
|
+
chat FAB whole — only the unread badge would stick out. The stylesheet resolves it by itself: when a
|
|
39
|
+
`bottom-end` dock is on the page, the scroll-top button steps **aside**, not up, because the
|
|
40
|
+
conversation panel stands above the FAB. It applies to `bottom-end` only and measures from the
|
|
41
|
+
default offset, so an application that sets `offset` positions the scroll-top button itself.
|
|
42
|
+
|
|
43
|
+
## The host lays out nothing
|
|
44
|
+
`.kpt-chat-dock-host` is `display: contents`, and both the FAB and the panel are `position: fixed`.
|
|
45
|
+
Dropped in the middle of an application's layout the dock takes no space in the flow.
|
|
46
|
+
|
|
47
|
+
## Closed is not removed
|
|
48
|
+
The panel stays in the DOM with `visibility: hidden` and a delayed transition — a transition
|
|
49
|
+
cannot animate out of `display: none`. It also carries `inert` and `aria-hidden` while closed, so
|
|
50
|
+
a hidden panel never catches the tab order.
|
|
51
|
+
|
|
52
|
+
## Examples
|
|
53
|
+
<KptChatDock unread={3} header="Support">
|
|
54
|
+
<KptChat messages={messages} currentUser="me" />
|
|
55
|
+
<KptChatComposer onSend={send} />
|
|
56
|
+
</KptChatDock>
|
|
57
|
+
|
|
58
|
+
<KptChatDock open={open} onOpenChange={setOpen} position="bottom-start" width="26rem">…</KptChatDock>
|
|
59
|
+
|
|
60
|
+
## i18n
|
|
61
|
+
`open`, `close`, `unreadBadge` (`{count}`) from the `chat` namespace.
|
|
62
|
+
|
|
63
|
+
## Tokens
|
|
64
|
+
The panel `--kpt-radius-lg` + `--kpt-elevation-3`, the corner offsets `--kpt-space-6`,
|
|
65
|
+
the stack `--kpt-z-overlay`.
|
|
66
|
+
|
|
67
|
+
## Accessibility
|
|
68
|
+
Opening moves focus to the composer inside the panel — that is what the chat is opened for; with no
|
|
69
|
+
field it takes the first interactive element. `Escape` closes and returns focus to the FAB, because
|
|
70
|
+
focus must not stay on an element that just left the accessibility tree. Below 640px the panel goes
|
|
71
|
+
full-screen.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { KptChat } from '../chat/chat.tsx';
|
|
2
|
+
export type { KptChatProps, KptChatReactEvent } from '../chat/chat.tsx';
|
|
3
|
+
export { KptChatMessage } from '../chat-message/chat-message.tsx';
|
|
4
|
+
export type { KptChatMessageProps, KptChatMessageVariant } from '../chat-message/chat-message.tsx';
|
|
5
|
+
export { KptChatTyping } from '../chat-typing/chat-typing.tsx';
|
|
6
|
+
export type { KptChatTypingProps } from '../chat-typing/chat-typing.tsx';
|
|
7
|
+
export { KptChatComposer } from '../chat-composer/chat-composer.tsx';
|
|
8
|
+
export type { KptChatComposerProps, KptChatComposerSubmit } from '../chat-composer/chat-composer.tsx';
|
|
9
|
+
export { KptChatDock } from '../chat-dock/chat-dock.tsx';
|
|
10
|
+
export type { KptChatDockProps, KptChatDockPosition } from '../chat-dock/chat-dock.tsx';
|
|
11
|
+
export type { KptChatAttachment, KptChatAuthor, KptChatDateLabel, KptChatDateRow, KptChatGroupOptions, KptChatGroupPosition, KptChatMessage as KptChatMessageModel, KptChatMessageRow, KptChatReaction, KptChatReactionKind, KptChatReactionOption, KptChatReplyRef, KptChatRow, KptChatStatus, KptChatUnreadRow, } from '@konce-pt/chat';
|
|
12
|
+
export { KPT_CHAT_GROUP_GAP_MS, KPT_CHAT_REACTIONS, buildChatRows, canRetry, chatDayIndicator, dateLabel, diffInDays, isSameDay, mergeStatus, pendingCount, replyRefFrom, startOfDay, statusRank, toDate, toggleReaction, } from '@konce-pt/chat';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @konce-pt/react/chat — rozmowa. Osobny subpath, bo dock i logika listy są ciężkie
|
|
3
|
+
* względem rdzenia biblioteki i nie każda aplikacja ich potrzebuje.
|
|
4
|
+
*/
|
|
5
|
+
export { KptChat } from "../chat/chat.js";
|
|
6
|
+
export { KptChatMessage } from "../chat-message/chat-message.js";
|
|
7
|
+
export { KptChatTyping } from "../chat-typing/chat-typing.js";
|
|
8
|
+
export { KptChatComposer } from "../chat-composer/chat-composer.js";
|
|
9
|
+
export { KptChatDock } from "../chat-dock/chat-dock.js";
|
|
10
|
+
export { KPT_CHAT_GROUP_GAP_MS, KPT_CHAT_REACTIONS, buildChatRows, canRetry, chatDayIndicator, dateLabel, diffInDays, isSameDay, mergeStatus, pendingCount, replyRefFrom, startOfDay, statusRank, toDate, toggleReaction, } from '@konce-pt/chat';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ComponentPropsWithRef, type ReactNode } from 'react';
|
|
2
|
+
import type { KptChatAttachment, KptChatAuthor, KptChatGroupPosition, KptChatMessage as KptChatMessageModel, KptChatReaction, KptChatReactionOption, KptChatReplyRef, KptChatStatus } from '@konce-pt/chat';
|
|
3
|
+
import { type KptI18nOverrides } from '../i18n/i18n-context.tsx';
|
|
4
|
+
/** Wygląd bąbla. `system` nie rysuje bąbla w ogóle — to komunikat, nie wypowiedź. */
|
|
5
|
+
export type KptChatMessageVariant = 'neutral' | 'primary' | 'solid' | 'ghost' | 'system';
|
|
6
|
+
export interface KptChatMessageProps extends Omit<ComponentPropsWithRef<'div'>, 'children'>, KptI18nOverrides {
|
|
7
|
+
/** Cała wiadomość naraz. Pojedyncze propsy poniżej mają nad nią priorytet. */
|
|
8
|
+
message?: KptChatMessageModel | null;
|
|
9
|
+
author?: KptChatAuthor | null;
|
|
10
|
+
text?: string | null;
|
|
11
|
+
time?: Date | string | number | null;
|
|
12
|
+
attachments?: readonly KptChatAttachment[] | null;
|
|
13
|
+
reactions?: readonly KptChatReaction[] | null;
|
|
14
|
+
replyTo?: KptChatReplyRef | null;
|
|
15
|
+
status?: KptChatStatus | null;
|
|
16
|
+
/** Strona listy: `start` to cudza wiadomość, `end` własna. */
|
|
17
|
+
align?: 'start' | 'end';
|
|
18
|
+
/** Wariant kolorystyczny; bez niego wynika z `align` (własna = `primary`). */
|
|
19
|
+
variant?: KptChatMessageVariant | null;
|
|
20
|
+
position?: KptChatGroupPosition;
|
|
21
|
+
system?: boolean;
|
|
22
|
+
showAuthor?: boolean;
|
|
23
|
+
showAvatar?: boolean;
|
|
24
|
+
showMeta?: boolean;
|
|
25
|
+
tail?: boolean;
|
|
26
|
+
/** Akcje pod kursorem: odpowiedz i zareaguj. Domyślnie wyłączone. */
|
|
27
|
+
actions?: boolean;
|
|
28
|
+
/** Zestaw wybieraka reakcji. Domyślnie pięć wbudowanych; można go skrócić lub przestawić. */
|
|
29
|
+
reactionOptions?: readonly KptChatReactionOption[];
|
|
30
|
+
onRetry?: () => void;
|
|
31
|
+
onReply?: () => void;
|
|
32
|
+
/** Wybrana reakcja (emoji). Biblioteka niczego nie dopisuje do modelu — robi to aplikacja. */
|
|
33
|
+
onReact?: (emoji: string) => void;
|
|
34
|
+
/** Kliknięcie w cytat — aplikacja może przewinąć listę do oryginału. */
|
|
35
|
+
onQuoted?: (quote: KptChatReplyRef) => void;
|
|
36
|
+
/** Treść bąbla. Wygrywa z `text`, więc bąbel przyjmuje cokolwiek. */
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Pojedyncza wiadomość czatu — bąbel z treścią, godziną, statusem wysyłki i opcjonalnym
|
|
41
|
+
* awatarem, cytatem, załącznikami i reakcjami.
|
|
42
|
+
*
|
|
43
|
+
* Działa na dwa sposoby: dostaje gotowy obiekt przez `message` (tak używa go `KptChat`)
|
|
44
|
+
* albo pojedyncze propsy, gdy budujesz listę samodzielnie.
|
|
45
|
+
*
|
|
46
|
+
* Wygląd wewnątrz grupy steruje `position`: zapadnięty narożnik od strony nadawcy i ogonek
|
|
47
|
+
* tylko przy ostatnim bąblu. Wylicza to `buildChatRows()` z `@konce-pt/chat`.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* <KptChatMessage message={msg} align="end" position="last" />
|
|
51
|
+
* <KptChatMessage align="end" status="failed" onRetry={resend}>Nie poszło</KptChatMessage>
|
|
52
|
+
*/
|
|
53
|
+
export declare function KptChatMessage({ message, author, text, time, attachments, reactions, replyTo, status, align, variant, position, system, showAuthor, showAvatar, showMeta, tail, actions, reactionOptions, onRetry, onReply, onReact, onQuoted, children, locale, dictionary, className, ...rest }: Readonly<KptChatMessageProps>): import("react").JSX.Element;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { KPT_CHAT_REACTIONS, canRetry, toDate } from '@konce-pt/chat';
|
|
4
|
+
import { cn } from "../internal/cn.js";
|
|
5
|
+
import { useKptMessages } from "../i18n/i18n-context.js";
|
|
6
|
+
import { KptAvatar } from "../avatar/avatar.js";
|
|
7
|
+
import { KptIcon } from "../icon/icon.js";
|
|
8
|
+
import { KptIconButton } from "../icon-button/icon-button.js";
|
|
9
|
+
/** Ikona statusu wysyłki. `read` używa tej samej ikony co `delivered`, różni je kolor. */
|
|
10
|
+
const STATUS_ICONS = {
|
|
11
|
+
sending: 'clock',
|
|
12
|
+
sent: 'check',
|
|
13
|
+
delivered: 'checks',
|
|
14
|
+
read: 'checks',
|
|
15
|
+
failed: 'alert-triangle',
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Pojedyncza wiadomość czatu — bąbel z treścią, godziną, statusem wysyłki i opcjonalnym
|
|
19
|
+
* awatarem, cytatem, załącznikami i reakcjami.
|
|
20
|
+
*
|
|
21
|
+
* Działa na dwa sposoby: dostaje gotowy obiekt przez `message` (tak używa go `KptChat`)
|
|
22
|
+
* albo pojedyncze propsy, gdy budujesz listę samodzielnie.
|
|
23
|
+
*
|
|
24
|
+
* Wygląd wewnątrz grupy steruje `position`: zapadnięty narożnik od strony nadawcy i ogonek
|
|
25
|
+
* tylko przy ostatnim bąblu. Wylicza to `buildChatRows()` z `@konce-pt/chat`.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <KptChatMessage message={msg} align="end" position="last" />
|
|
29
|
+
* <KptChatMessage align="end" status="failed" onRetry={resend}>Nie poszło</KptChatMessage>
|
|
30
|
+
*/
|
|
31
|
+
export function KptChatMessage({ message = null, author = null, text = null, time = null, attachments = null, reactions = null, replyTo = null, status = null, align = 'start', variant = null, position = 'single', system = false, showAuthor = false, showAvatar = false, showMeta = true, tail = true, actions = false, reactionOptions = KPT_CHAT_REACTIONS, onRetry, onReply, onReact, onQuoted, children, locale, dictionary, className, ...rest }) {
|
|
32
|
+
const { t, locale: activeLocale } = useKptMessages({ locale, dictionary });
|
|
33
|
+
const rowRef = useRef(null);
|
|
34
|
+
/** Czy wybierak reakcji jest otwarty. */
|
|
35
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
36
|
+
// Kliknięcie obok zamyka wybierak. Sam `onBlur` nie wystarcza: w Safari kliknięcie w przycisk
|
|
37
|
+
// nie przenosi na niego fokusu, więc wybierak zostawałby otwarty na zawsze. Faza przechwytywania
|
|
38
|
+
// i `pointerdown` (nie `click`) — własne `onClick` opcji zdąży się wykonać przed zamknięciem.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!pickerOpen)
|
|
41
|
+
return;
|
|
42
|
+
const close = (event) => {
|
|
43
|
+
if (!rowRef.current?.contains(event.target))
|
|
44
|
+
setPickerOpen(false);
|
|
45
|
+
};
|
|
46
|
+
document.addEventListener('pointerdown', close, true);
|
|
47
|
+
return () => document.removeEventListener('pointerdown', close, true);
|
|
48
|
+
}, [pickerOpen]);
|
|
49
|
+
const isSystem = system || (message?.system ?? false);
|
|
50
|
+
const resolvedAuthor = author ?? message?.author ?? null;
|
|
51
|
+
const resolvedText = text ?? message?.text ?? '';
|
|
52
|
+
const resolvedStatus = status ?? message?.status ?? null;
|
|
53
|
+
const resolvedReplyTo = replyTo ?? message?.replyTo ?? null;
|
|
54
|
+
const resolvedAttachments = attachments ?? message?.attachments ?? [];
|
|
55
|
+
const resolvedReactions = reactions ?? message?.reactions ?? [];
|
|
56
|
+
const rawTime = time ?? message?.sentAt ?? null;
|
|
57
|
+
const sentAt = rawTime === null ? null : toDate(rawTime);
|
|
58
|
+
const timeLabel = sentAt
|
|
59
|
+
? new Intl.DateTimeFormat(activeLocale, { hour: '2-digit', minute: '2-digit' }).format(sentAt)
|
|
60
|
+
: '';
|
|
61
|
+
/*
|
|
62
|
+
* Wariant domyślny bierze się z wyrównania. Własna wiadomość dostaje pełny `solid`,
|
|
63
|
+
* bo motyw biblioteki jest monochromatyczny: pastelowy `primary-subtle` ma tę samą
|
|
64
|
+
* jasność co `muted`, więc własny i cudzy bąbel byłyby nie do odróżnienia. Kto woli
|
|
65
|
+
* ciszej, ustawia `variant="primary"` ręcznie.
|
|
66
|
+
*/
|
|
67
|
+
const resolvedVariant = isSystem
|
|
68
|
+
? 'system'
|
|
69
|
+
: (variant ?? (align === 'end' ? 'solid' : 'neutral'));
|
|
70
|
+
/* Ogonek tylko na końcu grupy i tylko tam, gdzie bąbel ma tło, z którego da się go wyciąć. */
|
|
71
|
+
const hasTail = tail &&
|
|
72
|
+
resolvedVariant !== 'ghost' &&
|
|
73
|
+
resolvedVariant !== 'system' &&
|
|
74
|
+
(position === 'last' || position === 'single');
|
|
75
|
+
const statusIcon = resolvedStatus ? STATUS_ICONS[resolvedStatus] : null;
|
|
76
|
+
const retryable = canRetry(resolvedStatus);
|
|
77
|
+
/*
|
|
78
|
+
* Etykieta dla czytnika ekranu. Bez niej z bąbla płynie sama treść — bez nadawcy,
|
|
79
|
+
* godziny i informacji, że wiadomość się nie wysłała.
|
|
80
|
+
*/
|
|
81
|
+
const ariaLabel = isSystem
|
|
82
|
+
? resolvedText
|
|
83
|
+
: [resolvedAuthor?.name, timeLabel, resolvedStatus ? t('chat.' + resolvedStatus) : '']
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join(', ');
|
|
86
|
+
if (isSystem) {
|
|
87
|
+
return (_jsx("div", { className: cn('kpt-chat-message-host', className), role: "note", "aria-label": ariaLabel, ...rest, children: _jsx("p", { className: "kpt-chat-message__system", children: resolvedText }) }));
|
|
88
|
+
}
|
|
89
|
+
return (_jsx("div", { className: cn('kpt-chat-message-host', className), role: "article", "aria-label": ariaLabel, ...rest, children: _jsxs("div", { ref: rowRef, className: "kpt-chat-message__row", "data-align": align, children: [align === 'start' ? (_jsx("span", { className: "kpt-chat-message__avatar", "aria-hidden": "true", children: showAvatar ? (_jsx(KptAvatar, { size: "sm", src: resolvedAuthor?.avatar ?? null, name: resolvedAuthor?.name ?? '' })) : null })) : null, _jsxs("div", { className: "kpt-chat-message__stack", children: [showAuthor && resolvedAuthor ? (_jsx("span", { className: "kpt-chat-message__author", children: resolvedAuthor.name })) : null, _jsxs("div", { className: "kpt-chat-message__bubble", "data-variant": resolvedVariant, "data-align": align, "data-position": position, "data-status": resolvedStatus ?? undefined, "data-tail": hasTail ? '' : undefined, children: [resolvedReplyTo ? (_jsxs("button", { type: "button", className: "kpt-chat-message__quote", onClick: () => onQuoted?.(resolvedReplyTo), children: [_jsx("span", { className: "kpt-chat-message__quote-author", children: resolvedReplyTo.author }), _jsx("span", { className: "kpt-chat-message__quote-text", children: resolvedReplyTo.excerpt })] })) : null, resolvedText ? _jsx("p", { className: "kpt-chat-message__text", children: resolvedText }) : null, children, resolvedAttachments.length ? (_jsx("ul", { className: "kpt-chat-message__files", children: resolvedAttachments.map((file) => (_jsxs("li", { className: "kpt-chat-message__file", children: [file.thumbnail ? (_jsx("img", { className: "kpt-chat-message__file-thumb", src: file.thumbnail, alt: file.name })) : (_jsx(KptIcon, { name: "paperclip" })), _jsx("a", { className: "kpt-chat-message__file-name", href: file.url, download: file.url ? file.name : undefined, children: file.name }), file.size ? (_jsx("span", { className: "kpt-chat-message__file-size", children: fileSize(file.size) })) : null] }, file.id))) })) : null, showMeta ? (_jsxs("span", { className: "kpt-chat-message__meta", children: [timeLabel ? (_jsx("time", { className: "kpt-chat-message__time", dateTime: sentAt?.toISOString(), children: timeLabel })) : null, statusIcon ? (_jsx(KptIcon, { className: "kpt-chat-message__status", name: statusIcon })) : null] })) : null, resolvedReactions.length ? (_jsx("ul", { className: "kpt-chat-message__reactions", "data-align": align, children: resolvedReactions.map((reaction) => (_jsxs("li", { className: "kpt-chat-message__reaction", "data-reacted": reaction.reacted ? '' : undefined, children: [_jsx("span", { "aria-hidden": "true", children: reaction.emoji }), _jsx("span", { className: "kpt-chat-message__reaction-count", children: reaction.count })] }, reaction.emoji))) })) : null] })] }), _jsx("span", { className: "kpt-chat-message__actions", "data-open": pickerOpen ? '' : undefined, children: retryable ? (_jsx(KptIconButton, { hostProps: { className: 'kpt-chat-message__retry' }, size: "sm", "aria-label": t('chat.retry'), onClick: () => onRetry?.(), children: _jsx(KptIcon, { name: "refresh" }) })) : actions ? (_jsxs(_Fragment, { children: [_jsx(KptIconButton, { size: "sm", "aria-label": t('chat.replyTo', { author: resolvedAuthor?.name ?? '' }), onClick: () => onReply?.(), children: _jsx(KptIcon, { name: "corner-up-left" }) }), _jsxs("span", { className: "kpt-chat-message__react", onKeyDown: (event) => {
|
|
90
|
+
if (event.key === 'Escape')
|
|
91
|
+
setPickerOpen(false);
|
|
92
|
+
}, onBlur: (event) => {
|
|
93
|
+
// Przechodzenie Tabem między opcjami wybieraka go nie zamyka.
|
|
94
|
+
if (!event.currentTarget.contains(event.relatedTarget))
|
|
95
|
+
setPickerOpen(false);
|
|
96
|
+
}, children: [_jsx(KptIconButton, { size: "sm", "aria-label": t('chat.react'), "aria-expanded": pickerOpen, onClick: () => setPickerOpen((open) => !open), children: _jsx(KptIcon, { name: "mood-smile" }) }), pickerOpen ? (_jsx("span", { className: "kpt-chat-message__picker", role: "group", "aria-label": t('chat.react'), children: reactionOptions.map((option) => {
|
|
97
|
+
const label = t('chat.react' + option.kind[0].toUpperCase() + option.kind.slice(1));
|
|
98
|
+
const reacted = resolvedReactions.some((item) => item.emoji === option.emoji && item.reacted);
|
|
99
|
+
return (_jsx("button", { type: "button", className: "kpt-chat-message__pick", "aria-label": label, title: label, "aria-pressed": reacted, onClick: () => {
|
|
100
|
+
onReact?.(option.emoji);
|
|
101
|
+
setPickerOpen(false);
|
|
102
|
+
}, children: _jsx("span", { "aria-hidden": "true", children: option.emoji }) }, option.kind));
|
|
103
|
+
}) })) : null] })] })) : null })] }) }));
|
|
104
|
+
}
|
|
105
|
+
/** Rozmiar załącznika w kB/MB — surowe bajty nic nie mówią przy pliku obok nazwy. */
|
|
106
|
+
function fileSize(bytes) {
|
|
107
|
+
if (bytes < 1024)
|
|
108
|
+
return bytes + ' B';
|
|
109
|
+
if (bytes < 1024 * 1024)
|
|
110
|
+
return Math.round(bytes / 1024) + ' kB';
|
|
111
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
112
|
+
}
|