28 Commits

Author SHA1 Message Date
9a87888f5a v1.6.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-18 08:16:46 +00:00
d61c3b6643 feat(conversation-selector): add conversation status badges to conversation selector and include status in sample data 2025-12-18 08:16:46 +00:00
c8554418de update 2025-12-17 11:50:04 +00:00
c1a8a57729 v1.5.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-17 11:41:47 +00:00
053d0c8e8f feat(combox): Introduce singleton SioCombox attached to document.body with open/close/toggle API and animated show/hide; integrate SioFab to use the singleton and update styles/positioning 2025-12-17 11:41:47 +00:00
55e8e192c9 v1.4.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-17 10:07:18 +00:00
286f6fd120 fix(ui): handle on-screen keyboard visibility to adjust layout and prevent inputs from being obscured 2025-12-17 10:07:18 +00:00
1401cd2c92 update 2025-12-17 09:27:53 +00:00
2323d1a01c update 2025-12-17 09:22:02 +00:00
bbb6d09ecf v1.4.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-17 09:13:52 +00:00
2f54ee3f85 feat(elements): update design tokens and sio-fab component; bump deps and update npmextra config 2025-12-17 09:13:52 +00:00
a066e0de73 v1.3.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-08 23:03:02 +00:00
7731054f0e feat(components): Add reusable message input component, refactor element properties to use accessor, update styles and docs, bump dependencies 2025-12-08 23:03:02 +00:00
5f48ecf7af update 2025-07-14 18:26:14 +00:00
f9d281c496 update 2025-07-14 18:22:02 +00:00
6e29f0b51a 1.2.5
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-14 18:14:44 +00:00
5e25f86a0b update 2025-07-14 18:14:40 +00:00
01dad7cc5e update 2025-07-14 18:13:02 +00:00
a4a3c6dc50 update 2025-07-14 17:44:52 +00:00
193b1f5234 update 2025-07-14 17:26:57 +00:00
95e92a5533 update 2025-07-14 15:31:15 +00:00
1caeae9ec9 update 2025-07-14 15:30:16 +00:00
c534d1d084 update 2025-07-14 15:24:41 +00:00
23592f3a15 update 2025-07-14 15:21:37 +00:00
66493f793f update 2025-07-14 15:11:47 +00:00
efd142d63d update 2025-07-14 15:07:39 +00:00
9ab16c85ba update 2025-07-14 14:54:54 +00:00
ba791ee18a update 2025-07-14 10:13:02 +00:00
27 changed files with 9451 additions and 5230 deletions

View File

@@ -1,5 +1,54 @@
# Changelog # Changelog
## 2025-12-18 - 1.6.0 - feat(conversation-selector)
add conversation status badges to conversation selector and include status in sample data
- Introduce TConversationStatus type and add optional status property to IConversation
- Render status badges in sio-conversation-selector with CSS classes and a getBadgeLabel helper
- Update sample conversations in sio-combox.ts to include statuses: 'new', 'needs-action', 'waiting', 'resolved'
## 2025-12-17 - 1.5.0 - feat(combox)
Introduce singleton SioCombox attached to document.body with open/close/toggle API and animated show/hide; integrate SioFab to use the singleton and update styles/positioning
- Add SioCombox.createOnBody() and SioCombox.getInstance() singletons
- Add isOpen state, open(), close(), toggle(), getIsOpen() and emit opened/closed/close events
- Move combox out of the FAB shadow DOM — attach to body and position fixed bottom-right with z-index and enter/exit transitions
- Update mobile layout to full-screen sizing and adjust transform origin for phablet
- Update SioFab to create the singleton on firstUpdated(), listen for close events, and toggle the singleton instead of rendering it inside the FAB
- Remove previous in-FAB combox container markup/CSS and hasShownOnce logic
- Minor visual/UX improvements: scale/opacity transitions, pointer-events control, and positioning variables for consistent behavior
## 2025-12-17 - 1.4.1 - fix(ui)
handle on-screen keyboard visibility to adjust layout and prevent inputs from being obscured
- Add keyboard visibility state (isKeyboardVisible) and keyboardBlurTimeout in sio-combox.ts
- Listen for custom 'input-focus' and 'input-blur' events and toggle keyboard-visible host attribute
- Dispatch 'input-focus'/'input-blur' from sio-conversation-selector and sio-message-input on focus/blur
- Add connected/disconnected lifecycle handlers and updated() hook to manage attribute and cleanup timeouts
- Apply :host([keyboard-visible]) CSS to set height to 100vh / 100dvh when keyboard is visible
## 2025-12-17 - 1.4.0 - feat(elements)
update design tokens and sio-fab component; bump deps and update npmextra config
- Refactor color tokens to a neutral HSL palette (ts_web/elements/00colors.ts) and adjust focus ring token (ts_web/elements/00tokens.ts).
- Refactor sio-fab: move styles to static property, add responsive FAB sizing and getMobileIconSize(), bind icon sizes, manage host class ('combox-open'), and tidy lifecycle methods for better behavior and mobile support.
- Bump dependencies and devDependencies: @design.estate/dees-wcctools -> ^2.0.1, lucide -> ^0.561.0; @git.zone/tsbuild -> ^4.0.2, @git.zone/tsrun -> ^2.0.1, @git.zone/tswatch -> ^2.3.13, @types/node -> ^25.0.3, etc.
- Update npmextra.json: rename configuration keys (gitzone -> @git.zone/cli, npmci -> @ship.zone/szci) and add release.registries and accessLevel for publishing.
## 2025-12-08 - 1.3.0 - feat(components)
Add reusable message input component, refactor element properties to use accessor, update styles and docs, bump dependencies
- Add new <sio-message-input> component (auto-resizing textarea, file picker, send/files events) and integrate it into sio-conversation-view
- Refactor multiple element class fields to use the 'accessor' property pattern (sio-button, sio-combox, sio-conversation-view, sio-dropdown-menu, sio-fab, sio-icon, sio-image-lightbox, sio-pdf-viewer, sio-recorder, etc.)
- Significant visual and UX updates to sio-button (new secondary variant, sizing, spacing, icon sizing, focus/disabled behavior)
- Move inline conversation input logic into the new component and simplify message handling (dispatch send-message event with text and attachments)
- Improve PDF viewer: safer async pdf.js loader, resize observer for responsive rendering, better error fallback and lifecycle cleanup
- Enhance image lightbox (PDF handling via sio-pdf-viewer, zoom/drag controls, keyboard shortcuts, download/open actions)
- Add icon caching and make lucide upgrade (cache limit, PascalCase lookup) to reduce re-renders
- Polish tokens/styles: spacing, radii, shadows, transitions and small responsive/layout tweaks (e.g. main page padding)
- Update README to reflect the public package name, components, quick start and development instructions
- Bump runtime and dev dependencies (dees-element/domtools/wcctools, lucide, git.zone tools, push.rocks smartenv/types) and adjust tsconfig (target/module settings simplified)
## 2025-04-20 - 1.2.4 - fix(build) ## 2025-04-20 - 1.2.4 - fix(build)
Update build script and async function signature Update build script and async function signature

56
demo.html Normal file
View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Social.io Catalog Demo</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: sans-serif;
background: #f5f5f5;
}
.demo-section {
margin: 20px 0;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h2 {
margin-top: 0;
}
.component-container {
position: relative;
height: 600px;
margin: 20px 0;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
sio-combox {
position: relative !important;
width: 100% !important;
height: 100% !important;
right: auto !important;
}
</style>
<script type="module" src="./dist_bundle/bundle.js"></script>
</head>
<body>
<div class="demo-section">
<h2>Combox Component</h2>
<div class="component-container">
<sio-combox></sio-combox>
</div>
</div>
<div class="demo-section">
<h2>FAB with Combox</h2>
<div style="position: relative; height: 700px;">
<sio-fab showCombox></sio-fab>
</div>
</div>
</body>
</html>

View File

@@ -1,5 +1,5 @@
{ {
"gitzone": { "@git.zone/cli": {
"projectType": "wcc", "projectType": "wcc",
"module": { "module": {
"githost": "gitlab.com", "githost": "gitlab.com",
@@ -9,11 +9,16 @@
"npmPackagename": "@social.io_private/catalog", "npmPackagename": "@social.io_private/catalog",
"license": "UNLICENSED", "license": "UNLICENSED",
"projectDomain": "social.io" "projectDomain": "social.io"
},
"release": {
"registries": [
"https://verdaccio.lossless.digital"
],
"accessLevel": "public"
} }
}, },
"npmci": { "@ship.zone/szci": {
"npmRegistryUrl": "verdaccio.lossless.one", "npmRegistryUrl": "verdaccio.lossless.one",
"npmGlobalTools": [], "npmGlobalTools": []
"npmAccessLevel": "private"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@social.io/catalog", "name": "@social.io/catalog",
"version": "1.2.4", "version": "1.6.0",
"private": false, "private": false,
"description": "catalog for social.io", "description": "catalog for social.io",
"main": "dist_ts_web/index.js", "main": "dist_ts_web/index.js",
@@ -15,26 +15,25 @@
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@design.estate/dees-catalog": "^1.5.6", "@design.estate/dees-domtools": "^2.3.6",
"@design.estate/dees-domtools": "^2.3.2", "@design.estate/dees-element": "^2.1.3",
"@design.estate/dees-element": "^2.0.42", "@design.estate/dees-wcctools": "^2.0.1",
"@design.estate/dees-wcctools": "^1.0.90",
"@losslessone_private/loint-pubapi": "^1.0.14", "@losslessone_private/loint-pubapi": "^1.0.14",
"@social.io/interfaces": "^1.2.1", "@social.io/interfaces": "^1.2.1",
"lucide": "^0.561.0",
"rrweb": "2.0.0-alpha.4", "rrweb": "2.0.0-alpha.4",
"rrweb-player": "1.0.0-alpha.4", "rrweb-player": "1.0.0-alpha.4",
"rrweb-snapshot": "2.0.0-alpha.4" "rrweb-snapshot": "2.0.0-alpha.4"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.3.2", "@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^2.2.5", "@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^1.3.3", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^1.0.96", "@git.zone/tstest": "^3.1.3",
"@git.zone/tswatch": "^2.1.0", "@git.zone/tswatch": "^2.3.13",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/smartenv": "^5.0.12", "@push.rocks/smartenv": "^6.0.0",
"@push.rocks/tapbundle": "^5.6.3", "@types/node": "^25.0.3"
"@types/node": "^22.14.1"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",

9014
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

220
readme.md
View File

@@ -1,34 +1,204 @@
# @social.io/private/catalog # @social.io/catalog
the element catalog for the lossless organization A modern, beautifully designed UI component library for building conversational interfaces and support chat experiences. Built with Lit Element and TypeScript.
## Availabililty and Links ## Issue Reporting and Security
- [npmjs.org (npm package)](https://www.npmjs.com/package/@social.io_private/catalog) For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
- [gitlab.com (source)](https://gitlab.com/social.io/private/catalog)
- [github.com (source mirror)](https://github.com/social.io/private/catalog)
- [docs (typedoc)](https://social.io/private.gitlab.io/catalog/)
## Status for master ## 🎯 Features
| Status Category | Status Badge | - **Complete Chat UI** - Ready-to-use conversation components with message threads, typing indicators, and attachments
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | - **Floating Action Button** - Eye-catching FAB with smooth animations for triggering the chat interface
| GitLab Pipelines | [![pipeline status](https://gitlab.com/social.io/private/catalog/badges/master/pipeline.svg)](https://lossless.cloud) | - **PDF Viewer** - Built-in PDF rendering with zoom, pagination, and download capabilities
| GitLab Pipline Test Coverage | [![coverage report](https://gitlab.com/social.io/private/catalog/badges/master/coverage.svg)](https://lossless.cloud) | - **Image Lightbox** - Full-featured lightbox with zoom, pan, and keyboard navigation
| npm | [![npm downloads per month](https://badgen.net/npm/dy/@social.io_private/catalog)](https://lossless.cloud) | - **Modern Design Tokens** - Consistent styling with customizable colors, spacing, typography, and shadows
| Snyk | [![Known Vulnerabilities](https://badgen.net/snyk/social.io/private/catalog)](https://lossless.cloud) | - **Dark Mode Ready** - Full light/dark theme support out of the box
| TypeScript Support | [![TypeScript](https://badgen.net/badge/TypeScript/>=%203.x/blue?icon=typescript)](https://lossless.cloud) | - **Accessibility** - Keyboard navigation and proper ARIA attributes
| node Support | [![node](https://img.shields.io/badge/node->=%2010.x.x-blue.svg)](https://nodejs.org/dist/latest-v10.x/docs/api/) | - **TypeScript First** - Full type definitions for all components
| Code Style | [![Code Style](https://badgen.net/badge/style/prettier/purple)](https://lossless.cloud) |
| PackagePhobia (total standalone install weight) | [![PackagePhobia](https://badgen.net/packagephobia/install/@social.io_private/catalog)](https://lossless.cloud) |
| PackagePhobia (package size on registry) | [![PackagePhobia](https://badgen.net/packagephobia/publish/@social.io_private/catalog)](https://lossless.cloud) |
| BundlePhobia (total size when bundled) | [![BundlePhobia](https://badgen.net/bundlephobia/minzip/@social.io_private/catalog)](https://lossless.cloud) |
## Usage ## 📦 Installation
For further information read the linked docs at the top of this readme. ```bash
npm install @social.io/catalog
# or
pnpm add @social.io/catalog
```
## Legal ## 🚀 Quick Start
> UNLICENSED licensed | **&copy;** [Task Venture Capital GmbH](https://task.vc) ```typescript
> | By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy) import { SioFab, SioCombox } from '@social.io/catalog';
// Components auto-register as custom elements
// Just use them in your HTML:
```
```html
<!-- Floating Action Button that opens the chat -->
<sio-fab></sio-fab>
<!-- Or use the full chat box directly -->
<sio-combox></sio-combox>
```
## 🧩 Components
### Core Components
| Component | Description |
|-----------|-------------|
| `<sio-fab>` | Floating action button with animated chat icon |
| `<sio-combox>` | Complete chat interface with conversation list and message view |
| `<sio-button>` | Styled button with variants (primary, secondary, destructive, outline, ghost) |
| `<sio-icon>` | Lucide icon wrapper with size and color customization |
| `<sio-dropdown-menu>` | Animated dropdown menu with keyboard support |
### Conversation Components
| Component | Description |
|-----------|-------------|
| `<sio-conversation-selector>` | Searchable list of conversations with unread indicators |
| `<sio-conversation-view>` | Message thread with typing indicators and file attachments |
| `<sio-message-input>` | Auto-expanding textarea with file upload |
### Media Components
| Component | Description |
|-----------|-------------|
| `<sio-image-lightbox>` | Fullscreen image viewer with zoom and pan |
| `<sio-pdf-viewer>` | PDF renderer with page navigation and zoom controls |
### Utility Components
| Component | Description |
|-----------|-------------|
| `<sio-recorder>` | Session recording using rrweb |
## 💅 Styling & Theming
The library uses CSS custom properties for theming. The design system includes:
- **Colors** - Primary, secondary, accent, destructive, muted, and semantic colors
- **Typography** - System font stack with size and weight variants
- **Spacing** - Consistent spacing scale (0.5rem increments)
- **Radius** - Border radius tokens from sm to full
- **Shadows** - Elevation system from sm to 2xl
- **Transitions** - Smooth animation presets
### Dark Mode
Dark mode is automatically supported. The components use `bdTheme()` helper that switches between light and dark values:
```typescript
import { bdTheme } from '@social.io/catalog';
// Usage in styles
css`
background: ${bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 10%)')};
`
```
## 📖 Usage Examples
### Basic Chat FAB
```html
<sio-fab></sio-fab>
```
The FAB opens a complete chat interface when clicked. It includes:
- Keyboard shortcut (Ctrl+S) to toggle
- Smooth scale and pulse animations
- Gradient background with glow effects
### Custom Button Variants
```html
<sio-button type="primary">Submit</sio-button>
<sio-button type="destructive">Delete</sio-button>
<sio-button type="outline">Cancel</sio-button>
<sio-button type="ghost" size="sm">
<sio-icon icon="settings"></sio-icon>
</sio-button>
```
### Image Lightbox
```typescript
const lightbox = document.querySelector('sio-image-lightbox');
lightbox.open({
url: 'https://example.com/photo.jpg',
name: 'My Photo',
size: 1024000
});
```
### PDF Viewer
```html
<sio-pdf-viewer
url="https://example.com/document.pdf"
fileName="document.pdf"
></sio-pdf-viewer>
```
### Dropdown Menu
```html
<sio-dropdown-menu
.items=${[
{ id: 'edit', label: 'Edit', icon: 'pencil' },
{ id: 'delete', label: 'Delete', icon: 'trash', destructive: true }
]}
@item-selected=${(e) => console.log('Selected:', e.detail.item)}
>
<sio-button type="ghost">
<sio-icon icon="more-vertical"></sio-icon>
</sio-button>
</sio-dropdown-menu>
```
## 🔧 Development
```bash
# Install dependencies
pnpm install
# Start development server with hot reload
pnpm watch
# Run tests
pnpm test
# Build for production
pnpm build
```
## 📚 Dependencies
- **@design.estate/dees-element** - Lit Element base with utilities
- **@design.estate/dees-domtools** - DOM manipulation helpers
- **lucide** - Beautiful open-source icons
- **rrweb** - Session recording/replay (for recorder component)
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -1,9 +1,149 @@
import { expect, expectAsync, tap, webhelpers } from '@push.rocks/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as socialioCatalog from '../ts_web/index.js'; import * as socialioCatalog from '../ts_web/index.js';
tap.test('', async () => { tap.test('render combox component', async () => {
const sioFab: socialioCatalog.SioFab = webhelpers.fixture(webhelpers.html`<sio-fab></sio-fab>`); // Create and add combox
const combox = new socialioCatalog.SioCombox();
combox.style.position = 'relative';
combox.style.width = '800px';
combox.style.height = '600px';
document.body.appendChild(combox);
await combox.updateComplete;
expect(combox).toBeInstanceOf(socialioCatalog.SioCombox);
// Check that the component rendered its content
const container = combox.shadowRoot.querySelector('.container');
expect(container).toBeTruthy();
const conversationSelector = combox.shadowRoot.querySelector('sio-conversation-selector');
expect(conversationSelector).toBeTruthy();
const conversationView = combox.shadowRoot.querySelector('sio-conversation-view');
expect(conversationView).toBeTruthy();
console.log('Combox component rendered successfully with all main elements');
document.body.removeChild(combox);
});
tap.test('render fab component', async () => {
// Create and add fab
const fab = new socialioCatalog.SioFab();
document.body.appendChild(fab);
await fab.updateComplete;
expect(fab).toBeInstanceOf(socialioCatalog.SioFab);
// Check main elements
const mainbox = fab.shadowRoot.querySelector('#mainbox');
expect(mainbox).toBeTruthy();
console.log('FAB component rendered successfully');
document.body.removeChild(fab);
});
tap.test('render image lightbox component', async () => {
// Create and add lightbox
const lightbox = new socialioCatalog.SioImageLightbox();
document.body.appendChild(lightbox);
await lightbox.updateComplete;
expect(lightbox).toBeInstanceOf(socialioCatalog.SioImageLightbox);
// Check main elements
const overlay = lightbox.shadowRoot.querySelector('.overlay');
expect(overlay).toBeTruthy();
const container = lightbox.shadowRoot.querySelector('.container');
expect(container).toBeTruthy();
// Test opening with an image
await lightbox.open({
url: 'https://picsum.photos/800/600',
name: 'Test Image',
size: 123456
});
await lightbox.updateComplete;
expect(lightbox.isOpen).toEqual(true);
// Test opening with a PDF
await lightbox.open({
url: 'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G',
name: 'test.pdf',
type: 'application/pdf',
size: 565
});
await lightbox.updateComplete;
// Check that PDF viewer is rendered
const pdfViewer = lightbox.shadowRoot.querySelector('sio-pdf-viewer');
expect(pdfViewer).toBeTruthy();
console.log('Image lightbox component rendered successfully with both image and PDF support');
document.body.removeChild(lightbox);
});
tap.test('render dropdown menu component', async () => {
// Create and add dropdown menu
const dropdown = new socialioCatalog.SioDropdownMenu();
dropdown.items = [
{ id: 'option1', label: 'Option 1', icon: 'settings' },
{ id: 'option2', label: 'Option 2', icon: 'user' },
{ id: 'divider', label: '', divider: true },
{ id: 'delete', label: 'Delete', icon: 'trash', destructive: true }
];
document.body.appendChild(dropdown);
await dropdown.updateComplete;
expect(dropdown).toBeInstanceOf(socialioCatalog.SioDropdownMenu);
// Check main elements
const trigger = dropdown.shadowRoot.querySelector('.trigger');
expect(trigger).toBeTruthy();
const dropdownElement = dropdown.shadowRoot.querySelector('.dropdown');
expect(dropdownElement).toBeTruthy();
// Check menu items
const menuItems = dropdown.shadowRoot.querySelectorAll('.menu-item');
expect(menuItems.length).toEqual(3); // 3 items (excluding divider)
console.log('Dropdown menu component rendered successfully');
document.body.removeChild(dropdown);
});
tap.test('render pdf viewer component', async () => {
// Create and add PDF viewer
const pdfViewer = new socialioCatalog.SioPdfViewer();
pdfViewer.style.width = '600px';
pdfViewer.style.height = '400px';
pdfViewer.url = 'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G';
pdfViewer.fileName = 'test.pdf';
document.body.appendChild(pdfViewer);
await pdfViewer.updateComplete;
expect(pdfViewer).toBeInstanceOf(socialioCatalog.SioPdfViewer);
// Check main elements
const container = pdfViewer.shadowRoot.querySelector('.container');
expect(container).toBeTruthy();
// PDF viewer uses canvas after loading, not iframe
// Just verify the component rendered correctly
expect(pdfViewer.url).toEqual('data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G');
expect(pdfViewer.fileName).toEqual('test.pdf');
console.log('PDF viewer component rendered successfully');
document.body.removeChild(pdfViewer);
}); });
tap.start(); tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@social.io/catalog', name: '@social.io/catalog',
version: '1.2.4', version: '1.6.0',
description: 'catalog for social.io' description: 'catalog for social.io'
} }

164
ts_web/elements/00colors.ts Normal file
View File

@@ -0,0 +1,164 @@
import { cssManager } from '@design.estate/dees-element';
export const colors = {
// Background colors - softer, more subtle
background: {
light: 'hsl(0 0% 100%)',
dark: 'hsl(0 0% 4%)'
},
// Foreground colors - less contrast for modern look
foreground: {
light: 'hsl(0 0% 4%)',
dark: 'hsl(0 0% 98%)'
},
// Card colors - subtle elevation
card: {
light: 'hsl(0 0% 100%)',
dark: 'hsl(0 0% 4%)'
},
cardForeground: {
light: 'hsl(0 0% 4%)',
dark: 'hsl(0 0% 98%)'
},
// Popover colors
popover: {
light: 'hsl(0 0% 100%)',
dark: 'hsl(0 0% 5%)'
},
popoverForeground: {
light: 'hsl(0 0% 5%)',
dark: 'hsl(0 0% 98%)'
},
// Primary colors - modern indigo/blue
primary: {
light: 'hsl(221.2 83.2% 53.3%)',
dark: 'hsl(217.2 91.2% 59.8%)'
},
primaryForeground: {
light: 'hsl(0 0% 98%)',
dark: 'hsl(0 0% 4%)'
},
// Secondary colors - more subtle
secondary: {
light: 'hsl(0 0% 96%)',
dark: 'hsl(0 0% 17%)'
},
secondaryForeground: {
light: 'hsl(0 0% 11%)',
dark: 'hsl(0 0% 98%)'
},
// Muted colors - softer grays
muted: {
light: 'hsl(0 0% 96%)',
dark: 'hsl(0 0% 17%)'
},
mutedForeground: {
light: 'hsl(0 0% 46%)',
dark: 'hsl(0 0% 65%)'
},
// Accent colors - subtle hover states
accent: {
light: 'hsl(0 0% 96%)',
dark: 'hsl(0 0% 17%)'
},
accentForeground: {
light: 'hsl(0 0% 11%)',
dark: 'hsl(0 0% 98%)'
},
// Destructive colors - softer red
destructive: {
light: 'hsl(0 72.2% 50.6%)',
dark: 'hsl(0 62.8% 30.6%)'
},
destructiveForeground: {
light: 'hsl(0 0% 98%)',
dark: 'hsl(0 0% 98%)'
},
// Border color - very subtle
border: {
light: 'hsl(0 0% 91%)',
dark: 'hsl(0 0% 17%)'
},
// Input color
input: {
light: 'hsl(0 0% 91%)',
dark: 'hsl(0 0% 18%)'
},
// Ring color - subtle focus indicator
ring: {
light: 'hsl(221.2 83.2% 53.3%)',
dark: 'hsl(217.2 91.2% 59.8%)'
},
// Success colors - modern green
success: {
light: 'hsl(142.1 70.6% 45.3%)',
dark: 'hsl(144.9 80.4% 10%)'
},
successForeground: {
light: 'hsl(0 0% 100%)',
dark: 'hsl(144.9 80.4% 80%)'
},
// Chart colors
chart1: {
light: 'hsl(12 76% 61%)',
dark: 'hsl(12 76% 61%)'
},
chart2: {
light: 'hsl(173 58% 39%)',
dark: 'hsl(173 58% 39%)'
},
chart3: {
light: 'hsl(197 37% 24%)',
dark: 'hsl(197 37% 24%)'
},
chart4: {
light: 'hsl(43 74% 66%)',
dark: 'hsl(43 74% 66%)'
},
chart5: {
light: 'hsl(27 87% 67%)',
dark: 'hsl(27 87% 67%)'
}
};
// Helper function to get color based on theme
export const getColor = (colorName: keyof typeof colors, isDark: boolean = false) => {
const color = colors[colorName];
return isDark ? color.dark : color.light;
};
// CSS helper for theme-aware colors
export const bdTheme = (colorNameOrLight: keyof typeof colors | string, dark?: string) => {
if (dark) {
// Direct color values provided
return cssManager.bdTheme(colorNameOrLight as string, dark);
}
// Color name from palette
const colorName = colorNameOrLight as keyof typeof colors;
return cssManager.bdTheme(colors[colorName].light, colors[colorName].dark);
};

145
ts_web/elements/00fonts.ts Normal file
View File

@@ -0,0 +1,145 @@
import { css } from '@design.estate/dees-element';
// Font families matching shadcn design
export const fontFamilies = {
sans: `'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif`,
mono: `'Geist Mono', 'SF Mono', Monaco, 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace`
};
// Font sizes following shadcn scale
export const fontSizes = {
xs: '0.75rem', // 12px
sm: '0.875rem', // 14px
base: '1rem', // 16px
lg: '1.125rem', // 18px
xl: '1.25rem', // 20px
'2xl': '1.5rem', // 24px
'3xl': '1.875rem', // 30px
'4xl': '2.25rem', // 36px
'5xl': '3rem', // 48px
'6xl': '3.75rem', // 60px
'7xl': '4.5rem', // 72px
'8xl': '6rem', // 96px
'9xl': '8rem' // 128px
};
// Line heights
export const lineHeights = {
none: '1',
tight: '1.25',
snug: '1.375',
normal: '1.5',
relaxed: '1.625',
loose: '2'
};
// Font weights
export const fontWeights = {
thin: '100',
extralight: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900'
};
// Letter spacing
export const letterSpacing = {
tighter: '-0.05em',
tight: '-0.025em',
normal: '0em',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em'
};
// Typography presets matching shadcn components
export const typography = {
h1: css`
font-size: 2.25rem;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.025em;
`,
h2: css`
font-size: 1.875rem;
line-height: 1.25;
font-weight: 600;
letter-spacing: -0.025em;
`,
h3: css`
font-size: 1.5rem;
line-height: 1.375;
font-weight: 600;
`,
h4: css`
font-size: 1.25rem;
line-height: 1.375;
font-weight: 600;
`,
body: css`
font-size: 1rem;
line-height: 1.5;
font-weight: 400;
`,
bodyLarge: css`
font-size: 1.125rem;
line-height: 1.625;
font-weight: 400;
`,
bodySmall: css`
font-size: 0.875rem;
line-height: 1.5;
font-weight: 400;
`,
caption: css`
font-size: 0.75rem;
line-height: 1.5;
font-weight: 400;
`,
code: css`
font-family: 'Geist Mono', 'SF Mono', Monaco, 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
font-weight: 400;
`,
button: css`
font-size: 0.875rem;
line-height: 1;
font-weight: 500;
letter-spacing: 0.025em;
`,
input: css`
font-size: 1rem;
line-height: 1.5;
font-weight: 400;
`
};
// Font loading CSS
export const fontLoadingStyles = css`
@font-face {
font-family: 'Geist Sans';
src: url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap');
font-display: swap;
}
`;

197
ts_web/elements/00tokens.ts Normal file
View File

@@ -0,0 +1,197 @@
import { css, cssManager } from '@design.estate/dees-element';
// Spacing scale
export const spacing = {
0: '0px',
px: '1px',
0.5: '0.125rem', // 2px
1: '0.25rem', // 4px
1.5: '0.375rem', // 6px
2: '0.5rem', // 8px
2.5: '0.625rem', // 10px
3: '0.75rem', // 12px
3.5: '0.875rem', // 14px
4: '1rem', // 16px
5: '1.25rem', // 20px
6: '1.5rem', // 24px
7: '1.75rem', // 28px
8: '2rem', // 32px
9: '2.25rem', // 36px
10: '2.5rem', // 40px
11: '2.75rem', // 44px
12: '3rem', // 48px
14: '3.5rem', // 56px
16: '4rem', // 64px
20: '5rem', // 80px
24: '6rem', // 96px
28: '7rem', // 112px
32: '8rem', // 128px
36: '9rem', // 144px
40: '10rem', // 160px
44: '11rem', // 176px
48: '12rem', // 192px
52: '13rem', // 208px
56: '14rem', // 224px
60: '15rem', // 240px
64: '16rem', // 256px
72: '18rem', // 288px
80: '20rem', // 320px
96: '24rem' // 384px
};
// Border radius
export const radius = {
none: '0px',
sm: '0.125rem', // 2px
DEFAULT: '0.375rem', // 6px - shadcn default
md: '0.375rem', // 6px
lg: '0.5rem', // 8px
xl: '0.75rem', // 12px
'2xl': '1rem', // 16px
'3xl': '1.5rem', // 24px
full: '9999px'
};
// Box shadows - more subtle for modern look
export const shadows = {
none: 'none',
sm: '0 1px 2px 0 rgb(0 0 0 / 0.03)',
DEFAULT: '0 2px 8px -2px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.04)',
md: '0 4px 12px -4px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.04)',
lg: '0 8px 24px -4px rgb(0 0 0 / 0.1), 0 2px 8px -2px rgb(0 0 0 / 0.04)',
xl: '0 24px 48px -12px rgb(0 0 0 / 0.18)',
'2xl': '0 32px 64px -12px rgb(0 0 0 / 0.14)',
inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.03)',
// Theme-aware shadows
card: cssManager.bdTheme(
'0 2px 8px -2px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.04)',
'0 2px 8px -2px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.2)'
),
dropdown: cssManager.bdTheme(
'0 8px 24px -4px rgb(0 0 0 / 0.1), 0 2px 8px -2px rgb(0 0 0 / 0.04)',
'0 8px 24px -4px rgb(0 0 0 / 0.3), 0 2px 8px -2px rgb(0 0 0 / 0.2)'
)
};
// Transitions
export const transitions = {
all: 'all 150ms cubic-bezier(0.4, 0, 0.2, 1)',
colors: 'color, background-color, border-color, text-decoration-color, fill, stroke 150ms cubic-bezier(0.4, 0, 0.2, 1)',
opacity: 'opacity 150ms cubic-bezier(0.4, 0, 0.2, 1)',
shadow: 'box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)',
// Durations
fast: '150ms',
normal: '200ms',
slow: '300ms',
// Timing functions
ease: 'cubic-bezier(0.4, 0, 0.2, 1)',
easeIn: 'cubic-bezier(0.4, 0, 1, 1)',
easeOut: 'cubic-bezier(0, 0, 0.2, 1)',
easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)'
};
// Z-index scale
export const zIndex = {
0: 0,
10: 10,
20: 20,
30: 30,
40: 40,
50: 50,
dropdown: 1000,
sticky: 1020,
fixed: 1030,
modalBackdrop: 1040,
modal: 1050,
popover: 1060,
tooltip: 1070
};
// Breakpoints
export const breakpoints = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px'
};
// Container sizes
export const containers = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
full: '100%'
};
// Common component sizes
export const sizes = {
// Button/Input heights
sm: '2rem', // 32px
DEFAULT: '2.5rem', // 40px
lg: '3rem', // 48px
// Icon sizes
icon: {
sm: '1rem', // 16px
DEFAULT: '1.25rem', // 20px
lg: '1.5rem' // 24px
}
};
// Animation keyframes
export const animations = {
fadeIn: css`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`,
slideIn: css`
@keyframes slideIn {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`,
slideUp: css`
@keyframes slideUp {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`,
scaleIn: css`
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`,
spin: css`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
};
// Focus ring styles
export const focusRing = css`
outline: 2px solid transparent;
outline-offset: 2px;
&:focus-visible {
outline-color: ${cssManager.bdTheme('hsl(0 0% 5%)', 'hsl(0 0% 84%)')};
}
`;
// Disabled styles
export const disabled = css`
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
`;

View File

@@ -1,5 +1,16 @@
export * from './sio-fab.js'; // Core components
export * from './sio-icon.js';
export * from './sio-button.js';
export * from './sio-dropdown-menu.js';
// Conversation components
export * from './sio-conversation-selector.js';
export * from './sio-conversation-view.js';
export * from './sio-message-input.js';
export * from './sio-combox.js'; export * from './sio-combox.js';
export * from './sio-subwidget-onboardme.js';
export * from './sio-subwidget-conversations.js'; // Other components
export * from './sio-fab.js';
export * from './sio-recorder.js'; export * from './sio-recorder.js';
export * from './sio-image-lightbox.js';
export * from './sio-pdf-viewer.js';

View File

@@ -0,0 +1,291 @@
import {
DeesElement,
html,
property,
customElement,
cssManager,
css,
unsafeCSS,
type TemplateResult,
} from '@design.estate/dees-element';
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies, typography } from './00fonts.js';
declare global {
interface HTMLElementTagNameMap {
'sio-button': SioButton;
}
}
@customElement('sio-button')
export class SioButton extends DeesElement {
public static demo = () => html`
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: center;">
<sio-button>Default</sio-button>
<sio-button type="primary">Primary</sio-button>
<sio-button type="destructive">Delete</sio-button>
<sio-button type="outline">Outline</sio-button>
<sio-button type="ghost">Ghost</sio-button>
<sio-button size="sm">Small</sio-button>
<sio-button size="lg">Large</sio-button>
<sio-button disabled>Disabled</sio-button>
</div>
`;
@property({ type: String })
public accessor text: string = '';
@property({ type: String })
public accessor type: 'default' | 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost' = 'default';
@property({ type: String })
public accessor size: 'sm' | 'default' | 'lg' = 'default';
@property({ type: Boolean, reflect: true })
public accessor disabled: boolean = false;
@property({ type: String })
public accessor status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: inline-block;
font-family: ${unsafeCSS(fontFamilies.sans)};
}
:host([disabled]) {
pointer-events: none;
}
.button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
border-radius: 6px;
font-weight: 500;
font-size: 14px;
line-height: 1;
letter-spacing: -0.01em;
transition: all 120ms ease;
cursor: pointer;
user-select: none;
outline: none;
border: none;
gap: 6px;
}
/* Size variants */
.button.size-sm {
height: 32px;
padding: 0 12px;
font-size: 13px;
}
.button.size-default {
height: 36px;
padding: 0 16px;
font-size: 14px;
}
.button.size-lg {
height: 42px;
padding: 0 24px;
font-size: 15px;
}
/* Type variants */
.button.default {
background: ${bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 15%)')};
color: ${bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 95%)')};
}
.button.default:hover:not(.disabled) {
background: ${bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 20%)')};
}
.button.default:active:not(.disabled) {
background: ${bdTheme('hsl(0 0% 87%)', 'hsl(0 0% 18%)')};
}
.button.primary {
background: ${bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 95%)')};
color: ${bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 0%)')};
}
.button.primary:hover:not(.disabled) {
background: ${bdTheme('hsl(0 0% 25%)', 'hsl(0 0% 100%)')};
}
.button.primary:active:not(.disabled) {
background: ${bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
}
/* Secondary variant */
.button.secondary {
background: transparent;
color: ${bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 95%)')};
box-shadow: inset 0 0 0 1px ${bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 25%)')};
}
.button.secondary:hover:not(.disabled) {
background: ${bdTheme('hsl(0 0% 96%)', 'hsl(0 0% 15%)')};
}
.button.secondary:active:not(.disabled) {
background: ${bdTheme('hsl(0 0% 92%)', 'hsl(0 0% 12%)')};
}
/* Destructive variant */
.button.destructive {
background: ${bdTheme('hsl(0 100% 95%)', 'hsl(0 50% 20%)')};
color: ${bdTheme('hsl(0 100% 45%)', 'hsl(0 100% 75%)')};
}
.button.destructive:hover:not(.disabled) {
background: ${bdTheme('hsl(0 100% 45%)', 'hsl(0 100% 50%)')};
color: white;
}
.button.destructive:active:not(.disabled) {
background: ${bdTheme('hsl(0 100% 40%)', 'hsl(0 100% 45%)')};
color: white;
}
.button.outline {
background: transparent;
color: ${bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
box-shadow: inset 0 0 0 1.5px ${bdTheme('hsl(0 0% 90%)', 'hsl(0 0% 30%)')};
}
.button.outline:hover:not(.disabled) {
color: ${bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 95%)')};
box-shadow: inset 0 0 0 1.5px ${bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 50%)')};
}
.button.outline:active:not(.disabled) {
background: ${bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 15%)')};
}
.button.ghost {
background: transparent;
color: ${bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
}
.button.ghost:hover:not(.disabled) {
color: ${bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 95%)')};
background: ${bdTheme('hsl(0 0% 0% / 0.05)', 'hsl(0 0% 100% / 0.05)')};
}
.button.ghost:active:not(.disabled) {
background: ${bdTheme('hsl(0 0% 0% / 0.1)', 'hsl(0 0% 100% / 0.1)')};
}
/* Status states */
.button.pending {
pointer-events: none;
opacity: 0.7;
}
.spinner {
position: absolute;
left: ${unsafeCSS(spacing["3"])};
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.button.success {
background: ${bdTheme('success')};
color: ${bdTheme('successForeground')};
pointer-events: none;
}
.button.error {
background: ${bdTheme('destructive')};
color: ${bdTheme('destructiveForeground')};
pointer-events: none;
}
/* Disabled state */
.button.disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* Focus state */
.button:focus-visible {
outline: 2px solid ${bdTheme('hsl(0 0% 15% / 0.2)', 'hsl(0 0% 95% / 0.2)')};
outline-offset: 2px;
}
/* Icon sizing within buttons */
.button sio-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.button.size-sm sio-icon {
width: 14px;
height: 14px;
}
.button.size-lg sio-icon {
width: 18px;
height: 18px;
}
`,
];
public render(): TemplateResult {
const buttonClasses = [
'button',
this.type === 'primary' ? 'primary' : this.type,
`size-${this.size}`,
this.disabled ? 'disabled' : '',
this.status,
].filter(Boolean).join(' ');
return html`
<button
class="${buttonClasses}"
?disabled=${this.disabled}
@click=${this.handleClick}
>
${this.status === 'pending' ? html`
<sio-icon class="spinner" icon="loader" size="16"></sio-icon>
` : ''}
${this.status === 'success' ? html`
<sio-icon icon="check" size="16"></sio-icon>
` : ''}
${this.status === 'error' ? html`
<sio-icon icon="x" size="16"></sio-icon>
` : ''}
<slot>${this.text}</slot>
</button>
`;
}
private handleClick(event: MouseEvent) {
if (this.disabled || this.status !== 'normal') {
event.preventDefault();
event.stopPropagation();
return;
}
// Let the native click bubble normally
// Don't dispatch a custom event to avoid double-triggering
}
}

View File

@@ -5,196 +5,486 @@ import {
customElement, customElement,
type TemplateResult, type TemplateResult,
cssManager, cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools'; import * as domtools from '@design.estate/dees-domtools';
import * as deesCatalog from '@design.estate/dees-catalog'; // Import design tokens
deesCatalog; import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies, typography } from './00fonts.js';
// Import components
import { SioConversationSelector, type IConversation } from './sio-conversation-selector.js';
import { SioConversationView, type IMessage, type IConversationData, type IAttachment } from './sio-conversation-view.js';
import { SioImageLightbox, type ILightboxImage } from './sio-image-lightbox.js';
// Make sure components are loaded
SioConversationSelector;
SioConversationView;
SioImageLightbox;
declare global {
interface HTMLElementTagNameMap {
'sio-combox': SioCombox;
}
}
@customElement('sio-combox') @customElement('sio-combox')
export class SioCombox extends DeesElement { export class SioCombox extends DeesElement {
public static demo = () => html` <sio-combox></sio-combox> `; public static demo = () => html` <sio-combox></sio-combox> `;
@property({ type: Object }) // Singleton instance
public referenceObject: HTMLElement; private static instance: SioCombox | null = null;
/** /**
* computes the button offset * Creates and appends a singleton combox to document.body
*/ */
public cssComputeHeight() { public static createOnBody(): SioCombox {
let height = window.innerHeight < 760 ? window.innerHeight : 760; if (!SioCombox.instance) {
if (!this.referenceObject) { SioCombox.instance = new SioCombox();
console.log('SioCombox: no reference object set'); document.body.appendChild(SioCombox.instance);
} }
if (this.referenceObject) { return SioCombox.instance;
console.log(`referenceObject height is ${this.referenceObject.clientHeight}`);
height = height - (this.referenceObject.clientHeight + 60);
}
return height;
} }
public cssComputeInnerScroll() { /**
console.log( * Gets the singleton instance if it exists
`SioCombox clientHeight: ${this.shadowRoot.querySelector('.mainbox').clientHeight}` */
); public static getInstance(): SioCombox | null {
console.log( return SioCombox.instance;
`SioCombox content scrollheight is: ${
this.shadowRoot.querySelector('.contentbox').clientHeight
}`
);
if (
this.shadowRoot.querySelector('.mainbox').clientHeight <
this.shadowRoot.querySelector('.contentbox').clientHeight
) {
(this.shadowRoot.querySelector('.mainbox') as HTMLElement).style.overflowY = 'scroll';
} else {
(this.shadowRoot.querySelector('.mainbox') as HTMLElement).style.overflowY = 'hidden';
} }
@property({ type: Object })
public accessor referenceObject: HTMLElement;
@state()
private accessor selectedConversationId: string | null = null;
@state()
private accessor isKeyboardVisible: boolean = false;
@state()
private accessor isOpen: boolean = false;
private keyboardBlurTimeout?: number;
@state()
private accessor conversations: IConversation[] = [
{
id: '1',
title: 'Technical Support',
lastMessage: 'Thanks for your help with the login issue!',
time: '2 min ago',
unread: true,
status: 'new',
},
{
id: '2',
title: 'Billing Question',
lastMessage: 'I need help understanding my invoice',
time: '1 hour ago',
status: 'needs-action',
},
{
id: '3',
title: 'Feature Request',
lastMessage: 'That would be great! Looking forward to it',
time: 'Yesterday',
status: 'waiting',
},
{
id: '4',
title: 'General Inquiry',
lastMessage: 'Thank you for the information',
time: '2 days ago',
status: 'resolved',
} }
];
@state()
private accessor messages: { [conversationId: string]: IMessage[] } = {
'1': [
{ id: '1', text: 'Hi, I\'m having trouble logging in', sender: 'user', time: '10:00 AM' },
{ id: '2', text: 'I can help you with that. Can you tell me what error you\'re seeing?', sender: 'support', time: '10:02 AM' },
{ id: '3', text: 'It says "Invalid credentials" but I\'m sure my password is correct', sender: 'user', time: '10:03 AM' },
{ id: '4', text: 'Let me check your account. Please try resetting your password using the forgot password link.', sender: 'support', time: '10:05 AM' },
{
id: '5',
text: 'Here\'s a screenshot of the error',
sender: 'user',
time: '10:08 AM',
attachments: [{
id: 'att1',
name: 'error-screenshot.png',
size: 245780,
type: 'image/png',
url: 'https://picsum.photos/400/300?random=1'
}]
},
{ id: '6', text: 'Thanks for your help with the login issue!', sender: 'user', time: '10:10 AM' },
{
id: '7',
text: 'Here is the documentation you requested',
sender: 'support',
time: '10:15 AM',
attachments: [{
id: 'att2',
name: 'user-guide.pdf',
size: 2457600,
type: 'application/pdf',
url: 'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G'
}]
},
],
'2': [
{ id: '1', text: 'I need help understanding my invoice', sender: 'user', time: '9:00 AM' },
{ id: '2', text: 'I\'d be happy to help explain your invoice. Which part is unclear?', sender: 'support', time: '9:05 AM' },
],
'3': [
{ id: '1', text: 'I\'d love to see dark mode support in your app!', sender: 'user', time: 'Yesterday' },
{ id: '2', text: 'Thanks for the suggestion! We\'re actually working on dark mode and it should be available next month.', sender: 'support', time: 'Yesterday' },
{ id: '3', text: 'That would be great! Looking forward to it', sender: 'user', time: 'Yesterday' },
],
'4': [
{ id: '1', text: 'Can you tell me more about your enterprise plans?', sender: 'user', time: '2 days ago' },
{ id: '2', text: 'Of course! Our enterprise plans include advanced features like SSO, dedicated support, and custom integrations.', sender: 'support', time: '2 days ago' },
{ id: '3', text: 'Thank you for the information', sender: 'user', time: '2 days ago' },
]
};
constructor() { constructor() {
super(); super();
domtools.DomTools.setupDomTools(); domtools.DomTools.setupDomTools();
} }
public render(): TemplateResult { async connectedCallback() {
return html` await super.connectedCallback();
${domtools.elementBasic.styles} this.addEventListener('input-focus', this.handleInputFocus as EventListener);
<style> this.addEventListener('input-blur', this.handleInputBlur as EventListener);
}
async disconnectedCallback() {
await super.disconnectedCallback();
this.removeEventListener('input-focus', this.handleInputFocus as EventListener);
this.removeEventListener('input-blur', this.handleInputBlur as EventListener);
if (this.keyboardBlurTimeout) {
clearTimeout(this.keyboardBlurTimeout);
}
}
private handleInputFocus = () => {
if (this.keyboardBlurTimeout) {
clearTimeout(this.keyboardBlurTimeout);
this.keyboardBlurTimeout = undefined;
}
this.isKeyboardVisible = true;
}
private handleInputBlur = () => {
if (this.keyboardBlurTimeout) {
clearTimeout(this.keyboardBlurTimeout);
}
this.keyboardBlurTimeout = window.setTimeout(() => {
this.isKeyboardVisible = false;
this.keyboardBlurTimeout = undefined;
}, 150);
}
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('isKeyboardVisible')) {
if (this.isKeyboardVisible) {
this.setAttribute('keyboard-visible', '');
} else {
this.removeAttribute('keyboard-visible');
}
}
if (changedProperties.has('isOpen')) {
if (this.isOpen) {
this.classList.add('open');
this.dispatchEvent(new CustomEvent('opened', { bubbles: true, composed: true }));
} else {
this.classList.remove('open');
this.dispatchEvent(new CustomEvent('closed', { bubbles: true, composed: true }));
}
}
}
/**
* Opens the combox
*/
public open() {
this.isOpen = true;
}
/**
* Closes the combox
*/
public close() {
this.isOpen = false;
this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
}
/**
* Toggles the combox open/closed state
*/
public toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
/**
* Returns whether the combox is currently open
*/
public getIsOpen(): boolean {
return this.isOpen;
}
public static styles = [
cssManager.defaultStyles,
css`
:host { :host {
overflow: hidden;
font-family: 'Dees Sans';
position: absolute;
display: block; display: block;
height: ${this.cssComputeHeight()}px; position: fixed;
width: 375px; bottom: 100px;
background: ${this.goBright ? '#eeeeee' : '#000000'}; right: 20px;
border-radius: 16px; height: 600px;
border: 1px solid rgba(250, 250, 250, 0.2); width: 800px;
right: 0px; background: ${bdTheme('background')};
z-index: 10000; border-radius: ${unsafeCSS(radius['2xl'])};
box-shadow: 0px 0px 5px ${this.goBright ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.5)'}; border: 1px solid ${bdTheme('border')};
color: ${this.goBright ? '#333' : '#ccc'}; box-shadow: ${unsafeCSS(shadows.xl)};
cursor: default;
user-select: none;
text-align: left;
}
.mainbox {
position: absolute;
height: 100%;
width: 100%;
overflow: hidden; overflow: hidden;
overscroll-behavior: contain; font-family: ${unsafeCSS(fontFamilies.sans)};
padding-bottom: 80px; transform-origin: bottom right;
z-index: 10001;
/* Hidden by default */
opacity: 0;
pointer-events: none;
transform: scale(0.95) translateY(10px);
transition: opacity 200ms ease, transform 200ms ease;
} }
.toppanel { :host(.open) {
height: 200px; opacity: 1;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.5); pointer-events: all;
padding: 20px; transform: scale(1) translateY(0);
--bg-color: ${this.goBright ? '#00000050' : '#ffffff30'};
--dot-color: #ffffff00;
--dot-size: 1px;
--dot-space: 6px;
background: linear-gradient(45deg, var(--bg-color) 1px, var(--dot-color) 1px) top left;
background-size: var(--dot-space) var(--dot-space);
margin-bottom: -50px;
} }
#greeting { :host::before {
padding-top: 50px; content: '';
font-family: 'Dees Sans';
margin: 0px;
font-size: 30px;
font-weight: 400;
}
#callToAction {
font-family: 'Dees Sans';
margin: 0px;
font-weight: 400;
}
.quicktabs {
position: absolute; position: absolute;
z-index: 100; inset: 0;
bottom: 30px; border-radius: ${unsafeCSS(radius['2xl'])};
display: grid; padding: 1px;
width: 100%; background: linear-gradient(145deg, ${bdTheme('border')}, transparent 50%);
padding-bottom: 16px; -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
grid-template-columns: repeat(2, 1fr); -webkit-mask-composite: exclude;
background-image: linear-gradient(to bottom, ${cssManager.bdTheme('#eeeeeb00', 'rgba(0, 0, 0, 0)')} 0%, ${cssManager.bdTheme('#eeeeebff', 'rgba(0, 0, 0, 1)')} 50%); mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
padding-top: 24px; mask-composite: exclude;
opacity: 0.5;
pointer-events: none;
} }
.quicktabs .quicktab { .container {
text-align: center; display: flex;
width: 100%; height: 100%;
} overflow: visible;
.quicktabs .quicktab .quicktabicon { border-radius: ${unsafeCSS(radius['2xl'])};
font-size: 20px;
margin-bottom: 8px;
} }
.quicktabs .quicktab .quicktabtext { /* Desktop layout (default) */
font-size: 12px; sio-conversation-selector {
font-weight: 600; width: 320px;
flex-shrink: 0;
} }
sio-conversation-view {
flex: 1;
}
`,
// Mobile responsive layout - full screen with sliding mechanics
cssManager.cssForPhablet(css`
:host {
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
border-radius: 0;
transform-origin: center center;
}
:host(.open) {
transform: scale(1) translateY(0);
}
.brandingbox { :host::before {
z-index: 101; border-radius: 0;
text-align: center; }
.container {
position: relative;
overflow: hidden;
}
sio-conversation-selector {
position: absolute; position: absolute;
width: 100%; width: 100%;
bottom: 0px; height: 100%;
left: 0px; transition: left 300ms ease, opacity 200ms ease;
font-size: 12px;
padding: 8px;
border-top: 1px solid rgba(250, 250, 250, 0.1);
font-family: 'Dees Sans';
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
background: ${this.goBright ? '#EEE' : '#000'};
color: ${this.goBright ? '#333' : '#777'};
} }
</style>
<div class="mainbox"> sio-conversation-view {
<div class="contentbox"> position: absolute;
<div class="toppanel"> width: 100%;
<div id="greeting">Hello :)</div> height: 100%;
<div id="callToAction">Ask us anything or share your feedback!</div> transition: left 300ms ease, opacity 200ms ease;
}
/* Mobile navigation states */
.container.show-list sio-conversation-selector {
left: 0;
opacity: 1;
}
.container.show-list sio-conversation-view {
left: 100%;
opacity: 0;
}
.container.show-conversation sio-conversation-selector {
left: -100%;
opacity: 0;
}
.container.show-conversation sio-conversation-view {
left: 0;
opacity: 1;
}
/* Keyboard visible adjustments */
:host([keyboard-visible]) {
height: 100vh;
height: 100dvh;
}
`),
];
public render(): TemplateResult {
const selectedConversation = this.selectedConversationId
? this.conversations.find(c => c.id === this.selectedConversationId)
: null;
const conversationData: IConversationData | null = selectedConversation
? {
id: selectedConversation.id,
title: selectedConversation.title,
messages: this.messages[selectedConversation.id] || []
}
: null;
const containerClass = this.selectedConversationId ? 'show-conversation' : 'show-list';
return html`
<div class="container ${containerClass}">
<sio-conversation-selector
.conversations=${this.conversations}
.selectedConversationId=${this.selectedConversationId}
@conversation-selected=${this.handleConversationSelected}
></sio-conversation-selector>
<sio-conversation-view
.conversation=${conversationData}
@back=${this.handleBack}
@send-message=${this.handleSendMessage}
@open-image=${this.handleOpenImage}
@open-file=${this.handleOpenImage}
></sio-conversation-view>
</div> </div>
<sio-subwidget-conversations></sio-subwidget-conversations>
<sio-subwidget-onboardme></sio-subwidget-onboardme> <sio-image-lightbox></sio-image-lightbox>
</div>
</div>
<div class="quicktabs">
<div class="quicktab">
<div class="quicktabicon">
<dees-icon iconFA="message"></dees-icon>
</div>
<div class="quicktabtext">Conversations</div>
</div>
<div class="quicktab">
<div class="quicktabicon">
<dees-icon iconFA="mugHot"></dees-icon>
</div>
<div class="quicktabtext">Onboarding</div>
</div>
</div>
<div class="brandingbox">powered by social.io</div>
`; `;
} }
async updated() { private handleConversationSelected(event: CustomEvent) {
this.cssComputeHeight(); const conversation = event.detail.conversation as IConversation;
window.requestAnimationFrame(() => { this.selectedConversationId = conversation.id;
// Mark conversation as read
const convIndex = this.conversations.findIndex(c => c.id === conversation.id);
if (convIndex !== -1) {
this.conversations[convIndex] = { ...this.conversations[convIndex], unread: false };
this.conversations = [...this.conversations];
}
}
private handleBack() {
// For mobile view, go back to conversation list
this.selectedConversationId = null;
}
private handleSendMessage(event: CustomEvent) {
const message = event.detail.message as IMessage;
const conversationId = this.selectedConversationId;
if (conversationId) {
// Add message to the conversation
if (!this.messages[conversationId]) {
this.messages[conversationId] = [];
}
this.messages[conversationId] = [...this.messages[conversationId], message];
this.messages = { ...this.messages };
// Update conversation's last message
const convIndex = this.conversations.findIndex(c => c.id === conversationId);
if (convIndex !== -1) {
this.conversations[convIndex] = {
...this.conversations[convIndex],
lastMessage: message.text,
time: 'Just now'
};
// Move conversation to top
const [conv] = this.conversations.splice(convIndex, 1);
this.conversations = [conv, ...this.conversations];
}
// Simulate a response after a delay (remove in production)
setTimeout(() => { setTimeout(() => {
this.cssComputeInnerScroll(); const responseMessage: IMessage = {
}, 200); id: Date.now().toString(),
}); text: 'Thanks for your message! We\'ll get back to you shortly.',
sender: 'support',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
};
this.messages[conversationId] = [...this.messages[conversationId], responseMessage];
this.messages = { ...this.messages };
}, 3000);
}
}
private handleOpenImage(event: CustomEvent) {
const attachment = event.detail.attachment as IAttachment;
const lightbox = this.shadowRoot?.querySelector('sio-image-lightbox') as SioImageLightbox;
if (lightbox && attachment) {
const lightboxFile: ILightboxImage = {
url: attachment.url,
name: attachment.name,
size: attachment.size,
type: attachment.type
};
lightbox.open(lightboxFile);
}
} }
} }

View File

@@ -0,0 +1,438 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element';
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies, typography } from './00fonts.js';
// Types
export type TConversationStatus = 'new' | 'waiting' | 'needs-action' | 'resolved';
export interface IConversation {
id: string;
title: string;
lastMessage: string;
time: string;
unread?: boolean;
avatar?: string;
status?: TConversationStatus;
}
declare global {
interface HTMLElementTagNameMap {
'sio-conversation-selector': SioConversationSelector;
}
}
@customElement('sio-conversation-selector')
export class SioConversationSelector extends DeesElement {
public static demo = () => html`
<sio-conversation-selector style="width: 320px; height: 600px;"></sio-conversation-selector>
`;
@property({ type: Array })
public accessor conversations: IConversation[] = [];
@property({ type: String })
public accessor selectedConversationId: string | null = null;
@state()
private accessor searchQuery: string = '';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: flex;
flex-direction: column;
height: 100%;
background: ${bdTheme('card')};
border-right: 1px solid ${bdTheme('border')};
font-family: ${unsafeCSS(fontFamilies.sans)};
}
.header {
padding: ${unsafeCSS(spacing["5"])} ${unsafeCSS(spacing["4"])};
border-bottom: 1px solid ${bdTheme('border')};
background: ${bdTheme('background')};
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: ${unsafeCSS(spacing["4"])};
}
.title {
font-size: 1.25rem;
line-height: 1.2;
font-weight: 600;
margin: 0;
color: ${bdTheme('foreground')};
letter-spacing: -0.025em;
}
.search-box {
position: relative;
}
.search-input {
width: 100%;
padding: ${unsafeCSS(spacing["2.5"])} ${unsafeCSS(spacing["10"])} ${unsafeCSS(spacing["2.5"])} ${unsafeCSS(spacing["3"])};
background: ${bdTheme('background')};
border: 1px solid ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.lg)};
font-size: 0.875rem;
color: ${bdTheme('foreground')};
outline: none;
transition: ${unsafeCSS(transitions.all)};
font-family: ${unsafeCSS(fontFamilies.sans)};
box-shadow: ${unsafeCSS(shadows.sm)};
}
.search-input::placeholder {
color: ${bdTheme('mutedForeground')};
font-weight: 400;
}
.search-input:focus {
border-color: ${bdTheme('ring')};
box-shadow: 0 0 0 3px ${bdTheme('ring')}20;
background: ${bdTheme('background')};
}
.search-icon {
position: absolute;
right: ${unsafeCSS(spacing["3"])};
top: 50%;
transform: translateY(-50%);
color: ${bdTheme('mutedForeground')};
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: ${unsafeCSS(spacing["2"])};
}
.conversation-item {
padding: ${unsafeCSS(spacing["3.5"])};
margin-bottom: ${unsafeCSS(spacing["1.5"])};
background: ${bdTheme('background')};
border: 1px solid transparent;
border-radius: ${unsafeCSS(radius.lg)};
cursor: pointer;
transition: ${unsafeCSS(transitions.all)};
position: relative;
}
.conversation-item:hover {
background: ${bdTheme('accent')};
transform: translateX(2px);
box-shadow: ${unsafeCSS(shadows.sm)};
}
.conversation-item.selected {
background: ${bdTheme('accent')};
border-color: ${bdTheme('border')};
box-shadow: ${unsafeCSS(shadows.sm)};
}
.conversation-item.selected::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: ${bdTheme('primary')};
border-radius: 0 3px 3px 0;
animation: slideIn 200ms ease-out;
}
@keyframes slideIn {
from {
width: 0;
opacity: 0;
}
to {
width: 3px;
opacity: 1;
}
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: ${unsafeCSS(spacing["1"])};
}
.conversation-title {
font-weight: 500;
color: ${bdTheme('foreground')};
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
font-size: 0.9375rem;
letter-spacing: -0.01em;
}
.conversation-time {
font-size: 0.75rem;
line-height: 1.5;
color: ${bdTheme('mutedForeground')};
opacity: 0.8;
}
.conversation-preview {
font-size: 0.8125rem;
line-height: 1.5;
color: ${bdTheme('mutedForeground')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: ${unsafeCSS(spacing["0.5"])};
}
.unread-dot {
display: inline-block;
width: 6px;
height: 6px;
background: ${bdTheme('primary')};
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
box-shadow: 0 0 0 0 ${bdTheme('primary')};
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 0 0 ${bdTheme('primary')}40;
}
50% {
opacity: 0.9;
transform: scale(1.05);
box-shadow: 0 0 0 4px ${bdTheme('primary')}00;
}
100% {
opacity: 1;
transform: scale(1);
box-shadow: 0 0 0 0 ${bdTheme('primary')}00;
}
}
.badge {
display: inline-flex;
align-items: center;
padding: 2px ${unsafeCSS(spacing["2"])};
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
border-radius: ${unsafeCSS(radius.full)};
white-space: nowrap;
}
.badge.new {
background: ${bdTheme('primary')}20;
color: ${bdTheme('primary')};
}
.badge.waiting {
background: ${bdTheme('muted')};
color: ${bdTheme('mutedForeground')};
}
.badge.needs-action {
background: hsl(38 92% 50% / 0.15);
color: hsl(38 92% 40%);
}
.badge.resolved {
background: ${bdTheme('success')}20;
color: ${bdTheme('success')};
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: ${unsafeCSS(spacing["4"])};
text-align: center;
color: ${bdTheme('mutedForeground')};
gap: ${unsafeCSS(spacing["3"])};
}
.empty-icon {
font-size: 48px;
opacity: 0.5;
}
/* Scrollbar styling */
.conversation-list::-webkit-scrollbar {
width: 6px;
}
.conversation-list::-webkit-scrollbar-track {
background: transparent;
}
.conversation-list::-webkit-scrollbar-thumb {
background: ${bdTheme('border')};
border-radius: 3px;
}
.conversation-list::-webkit-scrollbar-thumb:hover {
background: ${bdTheme('mutedForeground')};
}
.close-button {
display: none;
}
`,
// Mobile: show close button
cssManager.cssForPhablet(css`
.close-button {
display: flex;
}
`),
];
public render(): TemplateResult {
const filteredConversations = this.conversations.filter(conv =>
conv.title.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
conv.lastMessage.toLowerCase().includes(this.searchQuery.toLowerCase())
);
return html`
<div class="header">
<div class="header-top">
<sio-button
class="close-button"
type="ghost"
size="sm"
@click=${() => this.handleClose()}
>
<sio-icon icon="x" size="20"></sio-icon>
</sio-button>
<h2 class="title">Messages</h2>
<sio-button
type="primary"
size="sm"
@click=${() => this.startNewConversation()}
>
<sio-icon icon="plus" size="16"></sio-icon>
New
</sio-button>
</div>
<div class="search-box">
<input
type="text"
class="search-input"
placeholder="Search conversations..."
.value=${this.searchQuery}
@input=${(e: Event) => this.searchQuery = (e.target as HTMLInputElement).value}
@focus=${this.handleInputFocus}
@blur=${this.handleInputBlur}
/>
<sio-icon class="search-icon" icon="search" size="16"></sio-icon>
</div>
</div>
${filteredConversations.length > 0 ? html`
<div class="conversation-list">
${filteredConversations.map(conv => html`
<div
class="conversation-item ${this.selectedConversationId === conv.id ? 'selected' : ''}"
@click=${() => this.selectConversation(conv)}
>
<div class="conversation-header">
<span class="conversation-title">
${conv.title}
${conv.unread ? html`<span class="unread-dot"></span>` : ''}
${conv.status ? html`<span class="badge ${conv.status}">${this.getBadgeLabel(conv.status)}</span>` : ''}
</span>
<span class="conversation-time">${conv.time}</span>
</div>
<div class="conversation-preview">${conv.lastMessage}</div>
</div>
`)}
</div>
` : html`
<div class="empty-state">
<sio-icon class="empty-icon" icon="message-square"></sio-icon>
<h3>${this.searchQuery ? 'No matching conversations' : 'No conversations yet'}</h3>
<p>${this.searchQuery ? 'Try a different search term' : 'Start a new conversation to get started'}</p>
</div>
`}
`;
}
private selectConversation(conversation: IConversation) {
this.selectedConversationId = conversation.id;
// Dispatch event for parent components
this.dispatchEvent(new CustomEvent('conversation-selected', {
detail: { conversation },
bubbles: true,
composed: true
}));
}
private startNewConversation() {
// Dispatch event for parent components to handle new conversation creation
this.dispatchEvent(new CustomEvent('new-conversation', {
bubbles: true,
composed: true
}));
}
private handleClose() {
this.dispatchEvent(new CustomEvent('close', {
bubbles: true,
composed: true
}));
}
private handleInputFocus() {
setTimeout(() => {
this.dispatchEvent(new CustomEvent('input-focus', {
bubbles: true,
composed: true
}));
}, 50);
}
private handleInputBlur() {
this.dispatchEvent(new CustomEvent('input-blur', {
bubbles: true,
composed: true
}));
}
private getBadgeLabel(status: TConversationStatus): string {
const labels: Record<TConversationStatus, string> = {
'new': 'New',
'waiting': 'Waiting',
'needs-action': 'Action',
'resolved': 'Resolved',
};
return labels[status];
}
}

View File

@@ -0,0 +1,808 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element';
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies, typography } from './00fonts.js';
import { SioDropdownMenu, type IDropdownMenuItem } from './sio-dropdown-menu.js';
import { SioMessageInput } from './sio-message-input.js';
// Make sure components are loaded
SioDropdownMenu;
SioMessageInput;
// Types
export interface IAttachment {
id: string;
name: string;
size: number;
type: string;
url: string;
thumbnailUrl?: string;
}
export interface IMessage {
id: string;
text: string;
sender: 'user' | 'support';
time: string;
status?: 'sending' | 'sent' | 'delivered' | 'read';
attachments?: IAttachment[];
}
export interface IConversationData {
id: string;
title: string;
messages: IMessage[];
}
declare global {
interface HTMLElementTagNameMap {
'sio-conversation-view': SioConversationView;
}
}
@customElement('sio-conversation-view')
export class SioConversationView extends DeesElement {
public static demo = () => html`
<sio-conversation-view style="width: 600px; height: 600px;"></sio-conversation-view>
`;
@property({ type: Object })
public accessor conversation: IConversationData | null = null;
@state()
private accessor isTyping: boolean = false;
@state()
private accessor isDragging: boolean = false;
@state()
private accessor uploadingFiles: Map<string, { file: File; progress: number }> = new Map();
@state()
private accessor pendingAttachments: IAttachment[] = [];
private dropdownMenuItems: IDropdownMenuItem[] = [
{ id: 'mute', label: 'Mute notifications', icon: 'bell-off' },
{ id: 'pin', label: 'Pin conversation', icon: 'pin' },
{ id: 'search', label: 'Search in chat', icon: 'search' },
{ id: 'divider1', label: '', divider: true },
{ id: 'export', label: 'Export chat', icon: 'download' },
{ id: 'archive', label: 'Archive conversation', icon: 'archive' },
{ id: 'divider2', label: '', divider: true },
{ id: 'clear', label: 'Clear history', icon: 'trash-2', destructive: true }
];
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: flex;
flex-direction: column;
height: 100%;
background: ${bdTheme('background')};
font-family: ${unsafeCSS(fontFamilies.sans)};
}
.header {
padding: ${unsafeCSS(spacing["4"])};
border-bottom: 1px solid ${bdTheme('border')};
background: ${bdTheme('background')};
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["3"])};
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
position: sticky;
top: 0;
z-index: 10;
overflow: visible;
}
.back-button {
display: none;
}
@media (max-width: 600px) {
.back-button {
display: block;
}
}
.header-title {
font-size: 1.125rem;
line-height: 1.5;
font-weight: 600;
margin: 0;
color: ${bdTheme('foreground')};
flex: 1;
}
.header-actions {
display: flex;
gap: ${unsafeCSS(spacing["2"])};
position: relative;
overflow: visible;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: ${unsafeCSS(spacing["4"])};
display: flex;
flex-direction: column;
gap: ${unsafeCSS(spacing["3"])};
}
.message {
display: flex;
align-items: flex-start;
gap: ${unsafeCSS(spacing["3"])};
max-width: 70%;
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-bubble {
padding: ${unsafeCSS(spacing["2.5"])} ${unsafeCSS(spacing["3.5"])};
border-radius: ${unsafeCSS(radius["2xl"])};
font-size: 0.9375rem;
line-height: 1.6;
position: relative;
box-shadow: ${unsafeCSS(shadows.sm)};
max-width: 100%;
word-wrap: break-word;
}
.message.support .message-bubble {
background: ${bdTheme('secondary')};
color: ${bdTheme('secondaryForeground')};
border-bottom-left-radius: ${unsafeCSS(spacing["1"])};
border: 1px solid ${bdTheme('border')};
}
.message.user .message-bubble {
background: ${bdTheme('primary')};
color: ${bdTheme('primaryForeground')};
border-bottom-right-radius: ${unsafeCSS(spacing["1"])};
}
.message-time {
font-size: 0.75rem;
line-height: 1.5;
color: ${bdTheme('mutedForeground')};
margin-top: ${unsafeCSS(spacing["1"])};
}
.message.user .message-time {
text-align: right;
}
.typing-indicator {
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["1"])};
padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])};
background: ${bdTheme('muted')};
border-radius: ${unsafeCSS(radius.lg)};
width: fit-content;
}
.typing-dot {
width: 8px;
height: 8px;
background: ${bdTheme('mutedForeground')};
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
30% {
opacity: 1;
transform: scale(1);
}
}
.input-container {
padding: ${unsafeCSS(spacing["4"])};
border-top: 1px solid ${bdTheme('border')};
background: ${bdTheme('background')};
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: ${unsafeCSS(spacing["4"])};
padding: ${unsafeCSS(spacing["8"])};
text-align: center;
color: ${bdTheme('mutedForeground')};
animation: fadeIn 500ms ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.empty-icon {
font-size: 64px;
opacity: 0.5;
}
/* Scrollbar styling */
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb {
background: ${bdTheme('border')};
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: ${bdTheme('mutedForeground')};
}
/* File drop zone */
.messages-container {
position: relative;
}
.drop-overlay {
position: absolute;
inset: 0;
background: ${bdTheme('background')}95;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease;
}
.drop-overlay.active {
opacity: 1;
pointer-events: all;
}
.drop-zone {
padding: ${unsafeCSS(spacing["8"])};
border: 2px dashed ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.xl)};
background: ${bdTheme('card')};
text-align: center;
transition: ${unsafeCSS(transitions.all)};
}
.drop-overlay.active .drop-zone {
border-color: ${bdTheme('primary')};
background: ${bdTheme('accent')};
transform: scale(1.02);
}
.drop-icon {
font-size: 48px;
color: ${bdTheme('primary')};
margin-bottom: ${unsafeCSS(spacing["4"])};
}
.drop-text {
font-size: 1.125rem;
font-weight: 500;
color: ${bdTheme('foreground')};
margin-bottom: ${unsafeCSS(spacing["2"])};
}
.drop-hint {
font-size: 0.875rem;
color: ${bdTheme('mutedForeground')};
}
/* File attachments */
.message-attachments {
margin-top: ${unsafeCSS(spacing["2"])};
display: flex;
flex-wrap: wrap;
gap: ${unsafeCSS(spacing["2"])};
}
.attachment-image {
max-width: 200px;
max-height: 200px;
border-radius: ${unsafeCSS(radius.lg)};
overflow: hidden;
cursor: pointer;
transition: ${unsafeCSS(transitions.all)};
box-shadow: ${unsafeCSS(shadows.sm)};
}
.attachment-image:hover {
transform: scale(1.02);
box-shadow: ${unsafeCSS(shadows.md)};
}
.attachment-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])};
background: ${bdTheme('secondary')};
border: 1px solid ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.md)};
font-size: 0.875rem;
cursor: pointer;
transition: ${unsafeCSS(transitions.all)};
}
.attachment-file:hover {
background: ${bdTheme('accent')};
}
.attachment-name {
font-weight: 500;
color: ${bdTheme('foreground')};
}
.attachment-size {
color: ${bdTheme('mutedForeground')};
font-size: 0.75rem;
}
/* Pending attachments */
.pending-attachments {
padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])};
background: ${bdTheme('secondary')};
border: 1px solid ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.lg)};
margin-bottom: ${unsafeCSS(spacing["2"])};
}
.pending-attachment {
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
padding: ${unsafeCSS(spacing["1"])} 0;
}
.pending-attachment-info {
flex: 1;
min-width: 0;
}
.pending-attachment-name {
font-size: 0.875rem;
font-weight: 500;
color: ${bdTheme('foreground')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pending-attachment-size {
font-size: 0.75rem;
color: ${bdTheme('mutedForeground')};
}
.remove-attachment {
padding: ${unsafeCSS(spacing["1"])};
cursor: pointer;
color: ${bdTheme('mutedForeground')};
transition: ${unsafeCSS(transitions.all)};
}
.remove-attachment:hover {
color: ${bdTheme('destructive')};
}
`,
];
public render(): TemplateResult {
if (!this.conversation) {
return html`
<div class="empty-state">
<sio-icon class="empty-icon" icon="message-square"></sio-icon>
<h3>Select a conversation</h3>
<p>Choose a conversation from the sidebar to start messaging</p>
</div>
`;
}
return html`
<div class="header">
<sio-button
class="back-button"
type="ghost"
size="sm"
@click=${this.handleBack}
>
<sio-icon icon="arrow-left" size="16"></sio-icon>
</sio-button>
<h3 class="header-title">${this.conversation.title}</h3>
<div class="header-actions">
<sio-button type="ghost" size="sm">
<sio-icon icon="phone" size="16"></sio-icon>
</sio-button>
<sio-dropdown-menu
.items=${this.dropdownMenuItems}
@item-selected=${this.handleDropdownAction}
>
<sio-button type="ghost" size="sm">
<sio-icon icon="more-vertical" size="16"></sio-icon>
</sio-button>
</sio-dropdown-menu>
</div>
</div>
<div class="messages-container" id="messages"
@dragover=${this.handleDragOver}
@dragleave=${this.handleDragLeave}
@drop=${this.handleDrop}
@dragenter=${this.handleDragOver}
>
<div class="drop-overlay ${this.isDragging ? 'active' : ''}"
@drop=${this.handleDrop}
@dragover=${(e: DragEvent) => e.preventDefault()}
>
<div class="drop-zone">
<sio-icon class="drop-icon" icon="upload-cloud"></sio-icon>
<div class="drop-text">Drop files here</div>
<div class="drop-hint">Images and documents up to 10MB</div>
</div>
</div>
${this.conversation.messages.map((msg, index) => html`
<div class="message ${msg.sender}" style="animation-delay: ${index * 50}ms">
<div class="message-content">
<div class="message-bubble">
${msg.text}
</div>
${msg.attachments && msg.attachments.length > 0 ? html`
<div class="message-attachments">
${msg.attachments.map(attachment =>
this.isImage(attachment.type) ? html`
<div class="attachment-image" @click=${() => this.openImage(attachment)}>
<img src="${attachment.url}" alt="${attachment.name}" />
</div>
` : attachment.type?.includes('pdf') || attachment.name?.toLowerCase().endsWith('.pdf') ? html`
<div class="attachment-file" @click=${() => this.openImage(attachment)}>
<sio-icon icon="file-text" size="16"></sio-icon>
<div>
<div class="attachment-name">${attachment.name}</div>
<div class="attachment-size">${this.formatFileSize(attachment.size)}</div>
</div>
</div>
` : html`
<div class="attachment-file" @click=${() => this.downloadFile(attachment)}>
<sio-icon icon="file" size="16"></sio-icon>
<div>
<div class="attachment-name">${attachment.name}</div>
<div class="attachment-size">${this.formatFileSize(attachment.size)}</div>
</div>
</div>
`
)}
</div>
` : ''}
<div class="message-time">${msg.time}</div>
</div>
</div>
`)}
${this.isTyping ? html`
<div class="message support">
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
` : ''}
</div>
<div class="input-container">
${this.pendingAttachments.length > 0 ? html`
<div class="pending-attachments">
${this.pendingAttachments.map(attachment => html`
<div class="pending-attachment">
<sio-icon icon="${this.getFileIcon(attachment.type)}" size="16"></sio-icon>
<div class="pending-attachment-info">
<div class="pending-attachment-name">${attachment.name}</div>
<div class="pending-attachment-size">${this.formatFileSize(attachment.size)}</div>
</div>
<div class="remove-attachment" @click=${() => this.removeAttachment(attachment.id)}>
<sio-icon icon="x" size="16"></sio-icon>
</div>
</div>
`)}
</div>
` : ''}
<sio-message-input
@send-message=${this.handleMessageSend}
@files-selected=${this.handleFilesSelected}
></sio-message-input>
</div>
`;
}
private handleBack() {
this.dispatchEvent(new CustomEvent('back', {
bubbles: true,
composed: true
}));
}
private handleMessageSend(event: CustomEvent) {
const { text, attachments } = event.detail;
if (!text.trim() && attachments.length === 0) return;
const message: IMessage = {
id: Date.now().toString(),
text: text.trim(),
sender: 'user',
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
status: 'sending',
attachments: [...this.pendingAttachments]
};
// Dispatch event for parent to handle
this.dispatchEvent(new CustomEvent('send-message', {
detail: { message },
bubbles: true,
composed: true
}));
// Clear pending attachments
this.pendingAttachments = [];
// Simulate typing indicator (remove in production)
setTimeout(() => {
this.isTyping = true;
setTimeout(() => {
this.isTyping = false;
}, 2000);
}, 1000);
}
private handleFilesSelected(event: CustomEvent) {
const { files } = event.detail;
// Handle files if needed
// For now, we're handling attachments separately in the parent component
}
public updated() {
// Scroll to bottom when new messages arrive
const container = this.shadowRoot?.querySelector('#messages');
if (container) {
container.scrollTop = container.scrollHeight;
}
}
// File handling methods
private handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
}
private handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
// Check if we're actually leaving the messages container
const relatedTarget = e.relatedTarget as Node;
const container = e.currentTarget as HTMLElement;
if (!container.contains(relatedTarget)) {
this.isDragging = false;
}
}
private handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) {
this.processFiles(files);
}
}
private openFileSelector() {
const fileInput = this.shadowRoot?.querySelector('#fileInput') as HTMLInputElement;
if (fileInput) {
fileInput.click();
}
}
private handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
this.processFiles(files);
input.value = ''; // Clear input for re-selection
}
private async processFiles(files: File[]) {
const maxSize = 10 * 1024 * 1024; // 10MB
const validFiles = files.filter(file => {
if (file.size > maxSize) {
console.warn(`File ${file.name} exceeds 10MB limit`);
return false;
}
return true;
});
for (const file of validFiles) {
const id = `${Date.now()}-${Math.random()}`;
const url = await this.fileToDataUrl(file);
const attachment: IAttachment = {
id,
name: file.name,
size: file.size,
type: file.type,
url,
thumbnailUrl: this.isImage(file.type) ? url : undefined
};
this.pendingAttachments = [...this.pendingAttachments, attachment];
}
}
private fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
private removeAttachment(id: string) {
this.pendingAttachments = this.pendingAttachments.filter(a => a.id !== id);
}
private isImage(type: string): boolean {
return type.startsWith('image/');
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
private getFileIcon(type: string): string {
if (this.isImage(type)) return 'image';
if (type.includes('pdf')) return 'file-text';
if (type.includes('doc')) return 'file-text';
if (type.includes('sheet') || type.includes('excel')) return 'table';
return 'file';
}
private openImage(attachment: IAttachment) {
// Check if it's actually a PDF
if (attachment.type?.includes('pdf') || attachment.name?.toLowerCase().endsWith('.pdf')) {
this.dispatchEvent(new CustomEvent('open-file', {
detail: { attachment },
bubbles: true,
composed: true
}));
} else {
this.dispatchEvent(new CustomEvent('open-image', {
detail: { attachment },
bubbles: true,
composed: true
}));
}
}
private downloadFile(attachment: IAttachment) {
const a = document.createElement('a');
a.href = attachment.url;
a.download = attachment.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
private handleDropdownAction(event: CustomEvent) {
const { item } = event.detail as { item: IDropdownMenuItem };
// Dispatch event for parent to handle these actions
this.dispatchEvent(new CustomEvent('conversation-action', {
detail: {
action: item.id,
conversationId: this.conversation?.id
},
bubbles: true,
composed: true
}));
// Log action for demo purposes
console.log('Conversation action:', item.id, item.label);
// Handle some actions locally for demo
switch (item.id) {
case 'search':
// Could open a search overlay
console.log('Opening search...');
break;
case 'export':
// Export conversation as JSON/text
this.exportConversation();
break;
}
}
private exportConversation() {
if (!this.conversation) return;
const exportData = {
conversation: this.conversation.title,
exportDate: new Date().toISOString(),
messages: this.conversation.messages
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.conversation.title.replace(/\s+/g, '-')}-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,277 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element';
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies } from './00fonts.js';
export interface IDropdownMenuItem {
id: string;
label: string;
icon?: string;
divider?: boolean;
destructive?: boolean;
disabled?: boolean;
}
declare global {
interface HTMLElementTagNameMap {
'sio-dropdown-menu': SioDropdownMenu;
}
}
@customElement('sio-dropdown-menu')
export class SioDropdownMenu extends DeesElement {
public static demo = () => html`
<div style="position: relative; height: 200px; display: flex; justify-content: center; padding-top: 50px;">
<sio-dropdown-menu .items=${[
{ id: 'mute', label: 'Mute notifications', icon: 'bell-off' },
{ id: 'pin', label: 'Pin conversation', icon: 'pin' },
{ id: 'divider1', label: '', divider: true },
{ id: 'export', label: 'Export chat', icon: 'download' },
{ id: 'clear', label: 'Clear history', icon: 'trash-2', destructive: true }
]}>
<sio-button type="ghost" size="sm">
<sio-icon icon="more-vertical" size="16"></sio-icon>
</sio-button>
</sio-dropdown-menu>
</div>
`;
@property({ type: Array })
public accessor items: IDropdownMenuItem[] = [];
@property({ type: String })
public accessor align: 'left' | 'right' = 'right';
@state()
private accessor isOpen: boolean = false;
private documentClickHandler: (e: MouseEvent) => void;
private scrollHandler: () => void;
private resizeHandler: () => void;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: relative;
display: inline-block;
font-family: ${unsafeCSS(fontFamilies.sans)};
}
.trigger {
cursor: pointer;
}
.dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 200px;
background: ${bdTheme('background')};
border: 1px solid ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.lg)};
box-shadow: ${unsafeCSS(shadows.lg)};
overflow: hidden;
z-index: 100000;
opacity: 0;
transform: translateY(-10px) scale(0.95);
pointer-events: none;
transition: all 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: top right;
}
.dropdown.align-left {
right: auto;
left: 0;
transform-origin: top left;
}
.dropdown.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: all;
}
.menu-item {
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["3"])};
padding: ${unsafeCSS(spacing["2.5"])} ${unsafeCSS(spacing["3"])};
font-size: 0.875rem;
line-height: 1.5;
color: ${bdTheme('foreground')};
cursor: pointer;
transition: ${unsafeCSS(transitions.all)};
user-select: none;
border: none;
background: none;
width: 100%;
text-align: left;
}
.menu-item:hover:not(.disabled) {
background: ${bdTheme('accent')};
}
.menu-item:active:not(.disabled) {
transform: scale(0.98);
}
.menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item.destructive {
color: ${bdTheme('destructive')};
}
.menu-item.destructive:hover:not(.disabled) {
background: ${bdTheme('destructive')}10;
}
.menu-icon {
flex-shrink: 0;
color: ${bdTheme('mutedForeground')};
}
.menu-item.destructive .menu-icon {
color: ${bdTheme('destructive')};
}
.menu-label {
flex: 1;
}
.divider {
height: 1px;
background: ${bdTheme('border')};
margin: ${unsafeCSS(spacing["1"])} 0;
}
`,
];
public render(): TemplateResult {
return html`
<div class="trigger" @click=${this.toggleDropdown}>
<slot></slot>
</div>
<div class="dropdown ${this.isOpen ? 'open' : ''} align-${this.align}">
${this.items.map(item =>
item.divider ? html`
<div class="divider"></div>
` : html`
<button
class="menu-item ${item.destructive ? 'destructive' : ''} ${item.disabled ? 'disabled' : ''}"
@click=${() => this.handleItemClick(item)}
?disabled=${item.disabled}
>
${item.icon ? html`
<sio-icon class="menu-icon" icon="${item.icon}" size="16"></sio-icon>
` : ''}
<span class="menu-label">${item.label}</span>
</button>
`
)}
</div>
`;
}
private toggleDropdown = (e: Event) => {
console.log('[Dropdown] Toggle called, current state:', this.isOpen);
e.preventDefault();
e.stopPropagation();
this.isOpen = !this.isOpen;
console.log('[Dropdown] New state:', this.isOpen);
if (this.isOpen) {
this.addDocumentListener();
} else {
this.removeDocumentListener();
}
}
private updateDropdownPosition() {
// For absolute positioning, we don't need to calculate position dynamically
// The CSS handles it with top: calc(100% + 10px) and right: 0
console.log('[Dropdown] Position is handled by CSS (absolute positioning)');
}
private handleItemClick(item: IDropdownMenuItem) {
if (item.disabled || item.divider) return;
this.isOpen = false;
this.removeDocumentListener();
// Dispatch custom event with the selected item
this.dispatchEvent(new CustomEvent('item-selected', {
detail: { item },
bubbles: true,
composed: true
}));
}
private addDocumentListener() {
// Close dropdown when clicking outside
this.documentClickHandler = (e: MouseEvent) => {
const path = e.composedPath();
if (!path.includes(this)) {
this.isOpen = false;
this.removeDocumentListener();
}
};
// Update position on scroll/resize
this.scrollHandler = () => this.updateDropdownPosition();
this.resizeHandler = () => {
this.updateDropdownPosition();
if (window.innerWidth <= 600) {
// Close on mobile resize to prevent positioning issues
this.isOpen = false;
this.removeDocumentListener();
}
};
// Delay to avoid immediate closing
setTimeout(() => {
document.addEventListener('click', this.documentClickHandler);
window.addEventListener('scroll', this.scrollHandler, true);
window.addEventListener('resize', this.resizeHandler);
}, 0);
}
private removeDocumentListener() {
if (this.documentClickHandler) {
document.removeEventListener('click', this.documentClickHandler);
}
if (this.scrollHandler) {
window.removeEventListener('scroll', this.scrollHandler, true);
}
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
}
}
public async disconnectedCallback() {
await super.disconnectedCallback();
this.removeDocumentListener();
}
public close() {
this.isOpen = false;
this.removeDocumentListener();
}
}

View File

@@ -6,11 +6,20 @@ import {
type TemplateResult, type TemplateResult,
cssManager, cssManager,
css, css,
unsafeCSS,
state,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools'; import * as domtools from '@design.estate/dees-domtools';
import { SioCombox } from './sio-combox.js'; import { SioCombox } from './sio-combox.js';
import { SioIcon } from './sio-icon.js';
SioCombox; SioCombox;
SioIcon;
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions, sizes } from './00tokens.js';
import { fontFamilies, typography } from './00fonts.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -20,8 +29,11 @@ declare global {
@customElement('sio-fab') @customElement('sio-fab')
export class SioFab extends DeesElement { export class SioFab extends DeesElement {
@property() @property({ type: Boolean })
public showCombox = false; public accessor showCombox = false;
@state()
private accessor shouldPulse = false;
public static demo = () => html` <sio-fab .showCombox=${true}></sio-fab> `; public static demo = () => html` <sio-fab .showCombox=${true}></sio-fab> `;
@@ -30,10 +42,9 @@ export class SioFab extends DeesElement {
domtools.DomTools.setupDomTools(); domtools.DomTools.setupDomTools();
} }
public render(): TemplateResult { public static styles = [
return html` cssManager.defaultStyles,
${domtools.elementBasic.styles} css`
<style>
:host { :host {
will-change: transform; will-change: transform;
position: absolute; position: absolute;
@@ -42,126 +53,175 @@ export class SioFab extends DeesElement {
right: 20px; right: 20px;
z-index: 10000; z-index: 10000;
color: #fff; color: #fff;
--fab-gradient-start: #6366f1;
--fab-gradient-mid: #8b5cf6;
--fab-gradient-end: #a855f7;
--fab-gradient-hover-end: #c026d3;
--fab-shadow-color: rgba(139, 92, 246, 0.25);
--fab-size: 60px;
} }
#mainbox { #mainbox {
transition: all 0.2s; transition: ${unsafeCSS(transitions.all)};
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
right: 0px; right: 0px;
height: 60px; height: var(--fab-size);
width: 60px; width: var(--fab-size);
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 16px -2px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.06);
line-height: 60px; line-height: var(--fab-size);
text-align: center; text-align: center;
color: #ccc;
cursor: pointer; cursor: pointer;
background: ${this.goBright background: linear-gradient(135deg, var(--fab-gradient-start) 0%, var(--fab-gradient-mid) 50%, var(--fab-gradient-end) 100%);
? 'linear-gradient(-45deg, #eeeeeb, #eeeeeb)' color: white;
: 'linear-gradient(-45deg, #222222, #333333)'}; border-radius: ${unsafeCSS(radius.full)};
border-radius: 50% 50% 50% 50%;
user-select: none; user-select: none;
border: none;
animation: fabEntrance 300ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#mainbox::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 50%);
opacity: 0;
transition: opacity 200ms ease;
}
#mainbox::after {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
background: linear-gradient(135deg, var(--fab-gradient-start), var(--fab-gradient-end));
border-radius: inherit;
z-index: -1;
opacity: 0;
filter: blur(12px);
transition: opacity 300ms ease;
}
#mainbox:hover::before {
opacity: 1;
}
#mainbox:hover::after {
opacity: 0.3;
}
@keyframes fabEntrance {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
} }
#mainbox:hover { #mainbox:hover {
transform: scale(1.05); transform: scale(1.02);
background: linear-gradient(135deg, var(--fab-gradient-start) 0%, var(--fab-gradient-mid) 50%, var(--fab-gradient-hover-end) 100%);
box-shadow: 0 8px 20px -4px var(--fab-shadow-color);
} }
#mainbox:active { #mainbox:active {
transform: scale(0.95); transform: scale(0.98);
box-shadow: 0 4px 12px -2px var(--fab-shadow-color);
}
#mainbox.pulse::after {
animation: fabPulse 0.6s ease-out forwards;
}
@keyframes fabPulse {
0% {
box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.4);
opacity: 1;
}
100% {
box-shadow: 0 0 0 12px rgba(139, 92, 246, 0);
opacity: 0;
}
} }
#mainbox .icon { #mainbox .icon {
position: absolute; position: absolute;
top: 0px; top: 0px;
left: 0px; left: 0px;
will-change: transform; will-change: transform, opacity;
transform: ${this.showCombox ? 'rotate(0deg)' : 'rotate(-360deg)'}; transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.2s;
height: 100%; height: 100%;
width: 100%; width: 100%;
object-fit: contain; display: flex;
-webkit-user-drag: none; align-items: center;
-khtml-user-drag: none; justify-content: center;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
}
#mainbox .icon img {
filter: grayscale(1) ${cssManager.bdTheme('invert(1)', '')};
position: absolute;
width: 100%;
top: 0px;
left: 0px;
will-change: transform;
transform: scale(0.2, 0.2) translateY(-5px);
}
#mainbox .icon.open:hover img {
filter: grayscale(0);
} }
#mainbox .icon.open { #mainbox .icon.open {
opacity: ${this.showCombox ? '0' : '1'}; opacity: 1;
pointer-events: ${this.showCombox ? 'none' : 'all'}; transform: rotate(0deg) scale(1);
} }
#mainbox .icon.close { #mainbox .icon.close {
opacity: ${this.showCombox ? '1' : '0'};
pointer-events: ${this.showCombox ? 'all' : 'none'};
}
#mainbox .icon.close:hover dees-icon {
color: ${cssManager.bdTheme('#111', '#fff')};
}
#mainbox .icon.open dees-icon {
position: absolute;
width: 100%;
height: 100%;
font-size: 32px;
color: ${cssManager.bdTheme('#777', '#999')};
top: 0px;
left: 0px;
transform: translateY(2px);
}
#mainbox .icon.close dees-icon {
position: absolute;
width: 100%;
height: 100%;
font-size: 24px;
top: 0px;
left: 0px;
color: ${cssManager.bdTheme('#666', '#CCC')};
}
#comboxContainer sio-combox {
transition: transform 0.2s, opacity 0.2s;
will-change: transform;
transform: translateY(20px);
bottom: 80px;
opacity: 0; opacity: 0;
pointer-events: none; transform: rotate(-45deg) scale(0.9);
} }
#comboxContainer.show sio-combox { /* When combox is open */
transform: translateY(0px); :host(.combox-open) #mainbox .icon.open {
opacity: 1; opacity: 0;
pointer-events: all; transform: rotate(45deg) scale(0.9);
} }
</style>
<div id="mainbox" @click=${this.toggleCombox}> :host(.combox-open) #mainbox .icon.close {
opacity: 1;
transform: rotate(0deg) scale(1);
}
#mainbox .icon sio-icon {
color: white;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
#mainbox .icon.close sio-icon {
transform: scale(1);
}
`,
// Mobile responsive styles - smaller FAB
cssManager.cssForPhablet(css`
:host {
--fab-size: 48px;
bottom: 16px;
right: 16px;
}
`),
];
public render(): TemplateResult {
return html`
<div id="mainbox"
class="${this.shouldPulse ? 'pulse' : ''}"
@click=${this.toggleCombox}
@animationend=${() => { this.shouldPulse = false; }}
>
<div class="icon open"> <div class="icon open">
<dees-icon iconFA="message"></dees-icon> <sio-icon icon="message-square" size="24"></sio-icon>
<img src="https://assetbroker.lossless.one/brandfiles/00general/favicon_socialio.svg" />
</div> </div>
<div class="icon close"> <div class="icon close">
<dees-icon iconFa="xmark"></dees-icon> <sio-icon icon="x" size="20"></sio-icon>
</div> </div>
</div> </div>
<div id="comboxContainer" class="${this.showCombox ? 'show' : null}">
<sio-combox></sio-combox>
</div>
`; `;
} }
@@ -169,21 +229,47 @@ export class SioFab extends DeesElement {
* toggles the combox * toggles the combox
*/ */
public async toggleCombox() { public async toggleCombox() {
console.log('toggle combox'); const combox = SioCombox.getInstance();
this.showCombox = !this.showCombox; if (combox) {
const wasOpen = combox.getIsOpen();
combox.toggle();
this.showCombox = combox.getIsOpen();
if (this.showCombox && !wasOpen) {
this.shouldPulse = true;
}
}
} }
public async firstUpdated(args) { public async firstUpdated(args: any) {
super.firstUpdated(args); super.firstUpdated(args);
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
const sioCombox: SioCombox = this.shadowRoot.querySelector('sio-combox');
const mainBox: HTMLElement = this.shadowRoot.querySelector('#mainbox');
sioCombox.referenceObject = mainBox;
// Create the singleton combox on body
const combox = SioCombox.createOnBody();
// Listen for close events
combox.addEventListener('close', () => {
this.showCombox = false;
});
// Set up keyboard shortcut
domtools.keyboard domtools.keyboard
.on([domtools.keyboard.keyEnum.Ctrl, domtools.keyboard.keyEnum.S]) .on([domtools.keyboard.keyEnum.Ctrl, domtools.keyboard.keyEnum.S])
.subscribe((event) => { .subscribe(() => {
this.showCombox = !this.showCombox; this.toggleCombox();
}); });
} }
public async updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
// Update host class based on combox state
if (changedProperties.has('showCombox')) {
if (this.showCombox) {
this.classList.add('combox-open');
} else {
this.classList.remove('combox-open');
}
}
}
} }

163
ts_web/elements/sio-icon.ts Normal file
View File

@@ -0,0 +1,163 @@
import {
DeesElement,
html,
property,
customElement,
cssManager,
css,
type TemplateResult,
} from '@design.estate/dees-element';
import * as lucideIcons from 'lucide';
import { createElement } from 'lucide';
declare global {
interface HTMLElementTagNameMap {
'sio-icon': SioIcon;
}
}
@customElement('sio-icon')
export class SioIcon extends DeesElement {
public static demo = () => html`
<div style="display: flex; gap: 16px; align-items: center;">
<sio-icon icon="search"></sio-icon>
<sio-icon icon="message-square" color="#3b82f6"></sio-icon>
<sio-icon icon="x" size="32"></sio-icon>
<sio-icon icon="send" strokeWidth="3"></sio-icon>
</div>
`;
@property({ type: String })
public accessor icon: string;
@property({ type: Number })
public accessor size: number = 24;
@property({ type: String })
public accessor color: string = 'currentColor';
@property({ type: Number })
public accessor strokeWidth: number = 2;
// Cache for rendered icons
private static iconCache = new Map<string, string>();
private static readonly MAX_CACHE_SIZE = 100;
// Track last rendered properties to avoid unnecessary updates
private lastIcon: string | null = null;
private lastSize: number | null = null;
private lastColor: string | null = null;
private lastStrokeWidth: number | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
}
#iconContainer {
display: flex;
align-items: center;
justify-content: center;
}
#iconContainer svg {
display: block;
width: 100%;
height: 100%;
}
`,
];
public render(): TemplateResult {
return html`
<div id="iconContainer" style="width: ${this.size}px; height: ${this.size}px;"></div>
`;
}
public updated() {
// Check if we need to update
if (
this.lastIcon === this.icon &&
this.lastSize === this.size &&
this.lastColor === this.color &&
this.lastStrokeWidth === this.strokeWidth
) {
return;
}
// Update tracking properties
this.lastIcon = this.icon;
this.lastSize = this.size;
this.lastColor = this.color;
this.lastStrokeWidth = this.strokeWidth;
const container = this.shadowRoot?.querySelector('#iconContainer') as HTMLElement;
if (!container || !this.icon) return;
// Clear container
container.innerHTML = '';
// Create cache key
const cacheKey = `${this.icon}:${this.size}:${this.color}:${this.strokeWidth}`;
// Check cache
if (SioIcon.iconCache.has(cacheKey)) {
container.innerHTML = SioIcon.iconCache.get(cacheKey)!;
return;
}
try {
// Convert icon name to PascalCase (e.g., 'message-square' -> 'MessageSquare')
const pascalCaseName = this.icon
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const iconComponent = (lucideIcons as any)[pascalCaseName];
if (!iconComponent) {
console.warn(`Lucide icon '${pascalCaseName}' not found`);
return;
}
// Create the icon element
const svgElement = createElement(iconComponent, {
size: this.size,
color: this.color,
strokeWidth: this.strokeWidth,
});
if (svgElement) {
// Cache the result
const svgString = svgElement.outerHTML;
SioIcon.iconCache.set(cacheKey, svgString);
// Limit cache size
if (SioIcon.iconCache.size > SioIcon.MAX_CACHE_SIZE) {
const firstKey = SioIcon.iconCache.keys().next().value;
SioIcon.iconCache.delete(firstKey);
}
// Append to container
container.appendChild(svgElement);
}
} catch (error) {
console.error(`Error rendering icon ${this.icon}:`, error);
}
}
public async disconnectedCallback() {
await super.disconnectedCallback();
// Clear references
this.lastIcon = null;
this.lastSize = null;
this.lastColor = null;
this.lastStrokeWidth = null;
}
}

View File

@@ -0,0 +1,520 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element';
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies } from './00fonts.js';
// Import components
import { SioPdfViewer } from './sio-pdf-viewer.js';
SioPdfViewer;
export interface ILightboxFile {
url: string;
name: string;
size?: number;
type?: string;
}
// For backwards compatibility
export type ILightboxImage = ILightboxFile;
declare global {
interface HTMLElementTagNameMap {
'sio-image-lightbox': SioImageLightbox;
}
}
@customElement('sio-image-lightbox')
export class SioImageLightbox extends DeesElement {
public static demo = () => html`
<sio-image-lightbox .isOpen=${true} .file=${{
url: 'https://picsum.photos/800/600',
name: 'Demo Image',
type: 'image/jpeg'
}}></sio-image-lightbox>
`;
@property({ type: Boolean })
public accessor isOpen: boolean = false;
@property({ type: Object })
public accessor file: ILightboxFile | null = null;
// For backwards compatibility
public get image(): ILightboxFile | null {
return this.file;
}
public set image(value: ILightboxFile | null) {
this.file = value;
}
@state()
private accessor fileLoaded: boolean = false;
@state()
private accessor scale: number = 1;
@state()
private accessor translateX: number = 0;
@state()
private accessor translateY: number = 0;
private isDragging: boolean = false;
private startX: number = 0;
private startY: number = 0;
public static styles = [
cssManager.defaultStyles,
css`
:host {
position: fixed;
inset: 0;
z-index: 10000;
pointer-events: none;
font-family: ${unsafeCSS(fontFamilies.sans)};
isolation: isolate;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
transition: all 300ms ease;
pointer-events: none;
opacity: 0;
}
.overlay.open {
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
pointer-events: all;
opacity: 1;
}
.container {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: ${unsafeCSS(spacing["8"])};
pointer-events: none;
opacity: 0;
transform: scale(0.9);
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.container.open {
opacity: 1;
transform: scale(1);
pointer-events: all;
}
.content-wrapper {
position: relative;
max-width: 90vw;
max-height: 90vh;
cursor: grab;
user-select: none;
transition: transform 100ms ease-out;
z-index: 1;
}
.content-wrapper.dragging {
cursor: grabbing;
transition: none;
}
.content-wrapper.pdf {
cursor: default;
}
.image {
display: block;
max-width: 100%;
max-height: 90vh;
border-radius: ${unsafeCSS(radius.lg)};
box-shadow: ${unsafeCSS(shadows["2xl"])};
opacity: 0;
transition: opacity 300ms ease;
}
.image.loaded {
opacity: 1;
}
.pdf-viewer {
width: 90vw;
height: 90vh;
border-radius: ${unsafeCSS(radius.lg)};
box-shadow: ${unsafeCSS(shadows["2xl"])};
background: white;
opacity: 0;
transition: opacity 300ms ease;
border: none;
}
.pdf-viewer.loaded {
opacity: 1;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.controls {
position: absolute;
top: ${unsafeCSS(spacing["4"])};
right: ${unsafeCSS(spacing["4"])};
display: flex;
gap: ${unsafeCSS(spacing["2"])};
opacity: 0;
transition: opacity 200ms ease;
z-index: 10;
}
.container.open .controls {
opacity: 1;
}
.control-button {
width: 40px;
height: 40px;
border-radius: ${unsafeCSS(radius.full)};
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: ${unsafeCSS(transitions.all)};
}
.control-button:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.1);
}
.control-button:active {
transform: scale(0.95);
}
.info {
position: absolute;
bottom: ${unsafeCSS(spacing["4"])};
left: ${unsafeCSS(spacing["4"])};
right: ${unsafeCSS(spacing["4"])};
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: ${unsafeCSS(radius.lg)};
padding: ${unsafeCSS(spacing["3"])} ${unsafeCSS(spacing["4"])};
color: white;
display: flex;
justify-content: space-between;
align-items: center;
opacity: 0;
transform: translateY(10px);
transition: all 200ms ease;
z-index: 10;
}
.container.open .info {
opacity: 1;
transform: translateY(0);
}
.info-name {
font-weight: 500;
font-size: 0.9375rem;
}
.info-actions {
display: flex;
gap: ${unsafeCSS(spacing["3"])};
}
.info-button {
background: none;
border: none;
color: white;
opacity: 0.8;
cursor: pointer;
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["1"])};
font-size: 0.875rem;
padding: ${unsafeCSS(spacing["1"])} ${unsafeCSS(spacing["2"])};
border-radius: ${unsafeCSS(radius.md)};
transition: ${unsafeCSS(transitions.all)};
}
.info-button:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
@media (max-width: 600px) {
.container {
padding: ${unsafeCSS(spacing["4"])};
}
.controls {
top: ${unsafeCSS(spacing["2"])};
right: ${unsafeCSS(spacing["2"])};
}
.info {
bottom: ${unsafeCSS(spacing["2"])};
left: ${unsafeCSS(spacing["2"])};
right: ${unsafeCSS(spacing["2"])};
padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])};
}
}
`,
];
private isPDF(): boolean {
return this.file?.type?.includes('pdf') ||
this.file?.name?.toLowerCase().endsWith('.pdf') || false;
}
public render(): TemplateResult {
const contentStyle = this.scale !== 1 || this.translateX !== 0 || this.translateY !== 0
? `transform: scale(${this.scale}) translate(${this.translateX}px, ${this.translateY}px)`
: '';
const isPDF = this.isPDF();
return html`
<div class="overlay ${this.isOpen ? 'open' : ''}" @click=${(e: Event) => {
if (e.target === e.currentTarget) this.close(e);
}}></div>
<div class="container ${this.isOpen ? 'open' : ''}">
${this.file ? html`
<div class="controls">
${!isPDF ? html`
<div class="control-button" @click=${this.zoomIn}>
<sio-icon icon="zoom-in" size="18"></sio-icon>
</div>
<div class="control-button" @click=${this.zoomOut}>
<sio-icon icon="zoom-out" size="18"></sio-icon>
</div>
<div class="control-button" @click=${this.resetZoom}>
<sio-icon icon="maximize-2" size="18"></sio-icon>
</div>
` : ''}
<div class="control-button" @click=${(e: Event) => this.close(e)}>
<sio-icon icon="x" size="18"></sio-icon>
</div>
</div>
<div
class="content-wrapper ${this.isDragging ? 'dragging' : ''} ${isPDF ? 'pdf' : ''}"
style="${contentStyle}"
@mousedown=${!isPDF ? this.startDrag : undefined}
@mousemove=${!isPDF ? this.drag : undefined}
@mouseup=${!isPDF ? this.endDrag : undefined}
@mouseleave=${!isPDF ? this.endDrag : undefined}
@wheel=${this.handleWheel}
>
${!this.fileLoaded ? html`
<div class="loading">
<sio-icon class="spinner" icon="loader" size="24"></sio-icon>
<span>Loading...</span>
</div>
` : ''}
${isPDF ? html`
<sio-pdf-viewer
class="pdf-viewer ${this.fileLoaded ? 'loaded' : ''}"
.url="${this.file.url}"
.fileName="${this.file.name}"
@load=${() => this.fileLoaded = true}
></sio-pdf-viewer>
` : html`
<img
class="image ${this.fileLoaded ? 'loaded' : ''}"
src="${this.file.url}"
alt="${this.file.name}"
@load=${() => this.fileLoaded = true}
@error=${() => this.fileLoaded = false}
@click=${(e: Event) => e.stopPropagation()}
/>
`}
</div>
<div class="info">
<div class="info-name">${this.file.name}</div>
<div class="info-actions">
<button class="info-button" @click=${this.download}>
<sio-icon icon="download" size="16"></sio-icon>
Download
</button>
<button class="info-button" @click=${this.openInNewTab}>
<sio-icon icon="external-link" size="16"></sio-icon>
Open
</button>
</div>
</div>
` : ''}
</div>
`;
}
public async open(file: ILightboxFile | ILightboxImage) {
this.file = file;
this.fileLoaded = false;
this.resetZoom();
this.isOpen = true;
// For PDFs, we'll handle loading state differently since it's in a separate component
if (this.isPDF()) {
// PDFs are handled by sio-pdf-viewer which manages its own loading state
this.fileLoaded = true;
}
// Add keyboard listener
document.addEventListener('keydown', this.handleKeyDown);
}
private close = (e?: Event) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.isOpen = false;
document.removeEventListener('keydown', this.handleKeyDown);
// Dispatch close event
this.dispatchEvent(new CustomEvent('lightbox-close', {
bubbles: true,
composed: true
}));
// Clean up after animation
setTimeout(() => {
this.file = null;
this.fileLoaded = false;
this.resetZoom();
}, 300);
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this.close();
} else if (e.key === '+' || e.key === '=') {
this.zoomIn();
} else if (e.key === '-') {
this.zoomOut();
} else if (e.key === '0') {
this.resetZoom();
}
}
private zoomIn() {
this.scale = Math.min(this.scale * 1.2, 3);
}
private zoomOut() {
this.scale = Math.max(this.scale / 1.2, 0.5);
}
private resetZoom() {
this.scale = 1;
this.translateX = 0;
this.translateY = 0;
}
private handleWheel = (e: WheelEvent) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
// Zoom with ctrl/cmd + scroll
if (e.deltaY < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
}
private startDrag = (e: MouseEvent) => {
if (this.scale > 1) {
this.isDragging = true;
this.startX = e.clientX - this.translateX;
this.startY = e.clientY - this.translateY;
e.preventDefault();
}
}
private drag = (e: MouseEvent) => {
if (this.isDragging && this.scale > 1) {
this.translateX = e.clientX - this.startX;
this.translateY = e.clientY - this.startY;
}
}
private endDrag = () => {
this.isDragging = false;
}
private download() {
if (!this.file) return;
const a = document.createElement('a');
a.href = this.file.url;
a.download = this.file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
private openInNewTab() {
if (!this.file) return;
window.open(this.file.url, '_blank');
}
public async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('keydown', this.handleKeyDown);
}
}

View File

@@ -0,0 +1,297 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element';
// Import design tokens
import { colors, bdTheme } from './00colors.js';
import { spacing, radius, shadows, transitions } from './00tokens.js';
import { fontFamilies, typography } from './00fonts.js';
declare global {
interface HTMLElementTagNameMap {
'sio-message-input': SioMessageInput;
}
}
@customElement('sio-message-input')
export class SioMessageInput extends DeesElement {
public static demo = () => html`
<sio-message-input style="width: 600px;"></sio-message-input>
`;
@property({ type: String })
public accessor placeholder: string = 'Type a message...';
@property({ type: Boolean })
public accessor disabled: boolean = false;
@state()
private accessor messageText: string = '';
@state()
private accessor pendingAttachments: File[] = [];
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
font-family: ${unsafeCSS(fontFamilies.sans)};
}
.input-container {
display: flex;
align-items: flex-end;
background: ${bdTheme('secondary')};
border: 1px solid ${bdTheme('border')};
border-radius: 24px;
padding: 2px;
transition: all 200ms ease;
overflow: hidden;
}
.input-container:focus-within {
border-color: ${bdTheme('ring')};
background: ${bdTheme('background')};
}
.message-input {
flex: 1;
min-height: 44px;
max-height: 120px;
padding: 12px 16px;
background: transparent;
border: none;
font-size: 15px;
color: ${bdTheme('foreground')};
outline: none;
resize: none;
font-family: ${unsafeCSS(fontFamilies.sans)};
line-height: 1.5;
}
.message-input::placeholder {
color: ${bdTheme('mutedForeground')};
}
.action-buttons {
display: flex;
gap: 4px;
align-items: center;
padding-right: 2px;
}
.action-button {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
color: ${bdTheme('mutedForeground')};
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 120ms ease;
position: relative;
outline: none;
}
.action-button:hover {
color: ${bdTheme('foreground')};
background: ${bdTheme('hsl(0 0% 0% / 0.05)', 'hsl(0 0% 100% / 0.05)')};
}
.action-button:active {
transform: scale(0.95);
}
.action-button.attachment {
opacity: 0.7;
}
.action-button.attachment:hover {
opacity: 1;
}
.action-button.send {
background: ${bdTheme('primary')};
color: white;
margin-left: 2px;
}
.action-button.send:hover {
background: ${bdTheme('hsl(221 83% 49%)', 'hsl(217 91% 65%)')};
}
.action-button.send:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.action-button.send:disabled:hover {
background: ${bdTheme('primary')};
transform: none;
}
.file-input {
display: none;
}
/* Icon styles */
sio-icon {
width: 20px;
height: 20px;
}
.action-button.send sio-icon {
width: 18px;
height: 18px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-container">
<textarea
class="message-input"
placeholder="${this.placeholder}"
.value=${this.messageText}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
?disabled=${this.disabled}
rows="1"
></textarea>
<div class="action-buttons">
<input
type="file"
class="file-input"
id="fileInput"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
@change=${this.handleFileSelect}
/>
<button
class="action-button attachment"
@click=${this.openFileSelector}
?disabled=${this.disabled}
>
<sio-icon icon="paperclip"></sio-icon>
</button>
<button
class="action-button send"
?disabled=${!this.messageText.trim() || this.disabled}
@click=${this.sendMessage}
>
<sio-icon icon="send"></sio-icon>
</button>
</div>
</div>
`;
}
private handleInput(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
this.messageText = textarea.value;
// Auto-resize textarea
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
private handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
}
}
private handleFocus() {
setTimeout(() => {
this.dispatchEvent(new CustomEvent('input-focus', {
bubbles: true,
composed: true
}));
}, 50);
}
private handleBlur() {
this.dispatchEvent(new CustomEvent('input-blur', {
bubbles: true,
composed: true
}));
}
private sendMessage() {
if (!this.messageText.trim() && this.pendingAttachments.length === 0) {
return;
}
this.dispatchEvent(new CustomEvent('send-message', {
detail: {
text: this.messageText,
attachments: this.pendingAttachments
},
bubbles: true,
composed: true
}));
// Clear input
this.messageText = '';
this.pendingAttachments = [];
// Reset textarea height
const textarea = this.shadowRoot?.querySelector('.message-input') as HTMLTextAreaElement;
if (textarea) {
textarea.style.height = 'auto';
}
}
private openFileSelector() {
const fileInput = this.shadowRoot?.querySelector('#fileInput') as HTMLInputElement;
if (fileInput) {
fileInput.click();
}
}
private handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
this.pendingAttachments = [...this.pendingAttachments, ...files];
this.dispatchEvent(new CustomEvent('files-selected', {
detail: { files },
bubbles: true,
composed: true
}));
input.value = ''; // Clear input for re-selection
}
public focus() {
const textarea = this.shadowRoot?.querySelector('.message-input') as HTMLTextAreaElement;
textarea?.focus();
}
public clear() {
this.messageText = '';
this.pendingAttachments = [];
const textarea = this.shadowRoot?.querySelector('.message-input') as HTMLTextAreaElement;
if (textarea) {
textarea.style.height = 'auto';
}
}
}

View File

@@ -0,0 +1,593 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
cssManager,
css,
unsafeCSS,
state,
} from '@design.estate/dees-element';
// Import design tokens
import { bdTheme } from './00colors.js';
import { spacing, radius, shadows } from './00tokens.js';
import { fontFamilies } from './00fonts.js';
declare global {
interface HTMLElementTagNameMap {
'sio-pdf-viewer': SioPdfViewer;
}
interface Window {
pdfjsLib?: any;
}
}
@customElement('sio-pdf-viewer')
export class SioPdfViewer extends DeesElement {
public static demo = () => html`
<sio-pdf-viewer
.url=${'data:application/pdf;base64,JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovT3V0bGluZXMgMiAwIFIKL1BhZ2VzIDMgMCBSCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9PdXRsaW5lcwovQ291bnQgMAo+PgplbmRvYmoKMyAwIG9iago8PAovVHlwZSAvUGFnZXMKL0NvdW50IDEKL0tpZHMgWzQgMCBSXQo+PgplbmRvYmoKNCAwIG9iago8PAovVHlwZSAvUGFnZQovUGFyZW50IDMgMCBSCi9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgovUmVzb3VyY2VzIDw8Ci9Gb250IDw8Ci9GMSA2IDAgUgo+Pgo+Pgo+PgplbmRvYmoKNSAwIG9iago8PAovTGVuZ3RoIDQ0Cj4+CnN0cmVhbQpCVApxCjcwIDUwIFRECi9GMSAxMiBUZgooSGVsbG8gV29ybGQpIFRqCkVUClEKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqCjw8Ci9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL1RpbWVzLVJvbWFuCj4+CmVuZG9iagp4cmVmCjAgNwowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA3NCAwMDAwMCBuIAowMDAwMDAwMTIwIDAwMDAwIG4gCjAwMDAwMDAxNzkgMDAwMDAgbiAKMDAwMDAwMDM2NCAwMDAwMCBuIAowMDAwMDAwNDY2IDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUgNwovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKNTY1CiUlRU9G'}
.fileName=${'demo.pdf'}
style="width: 600px; height: 800px;"
></sio-pdf-viewer>
`;
@property({ type: String })
public accessor url: string = '';
@property({ type: String })
public accessor fileName: string = 'document.pdf';
@state()
private accessor isLoading: boolean = true;
@state()
private accessor hasError: boolean = false;
@state()
private accessor pdfDocument: any = null;
@state()
private accessor currentPage: number = 1;
@state()
private accessor totalPages: number = 0;
@state()
private accessor scale: number = 1;
private static pdfJsLoaded: boolean = false;
private static pdfJsLoading: Promise<void> | null = null;
private renderTask: any = null;
private resizeObserver: ResizeObserver | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
background: ${bdTheme('background')};
font-family: ${unsafeCSS(fontFamilies.sans)};
}
.container {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
}
.pdf-container {
width: 100%;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
background: ${bdTheme('muted')};
}
.pdf-canvas-wrapper {
position: relative;
margin: ${unsafeCSS(spacing["4"])} auto;
box-shadow: ${unsafeCSS(shadows.lg)};
background: white;
}
canvas {
display: block;
max-width: 100%;
height: auto;
}
.pdf-controls {
position: sticky;
top: 0;
z-index: 10;
width: 100%;
padding: ${unsafeCSS(spacing["3"])};
background: ${bdTheme('background')};
border-bottom: 1px solid ${bdTheme('border')};
display: flex;
align-items: center;
justify-content: center;
gap: ${unsafeCSS(spacing["3"])};
box-shadow: ${unsafeCSS(shadows.sm)};
}
.pdf-controls-group {
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
}
.page-info {
font-size: 0.875rem;
color: ${bdTheme('mutedForeground')};
min-width: 100px;
text-align: center;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: ${bdTheme('mutedForeground')};
}
.spinner {
animation: spin 1s linear infinite;
margin-bottom: ${unsafeCSS(spacing["2"])};
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.error-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: ${unsafeCSS(spacing["6"])};
background: ${bdTheme('card')};
border: 1px solid ${bdTheme('border')};
border-radius: ${unsafeCSS(radius.lg)};
box-shadow: ${unsafeCSS(shadows.md)};
max-width: 400px;
}
.error-icon {
color: ${bdTheme('destructive')};
margin-bottom: ${unsafeCSS(spacing["3"])};
}
.error-title {
font-size: 1.125rem;
font-weight: 600;
color: ${bdTheme('foreground')};
margin-bottom: ${unsafeCSS(spacing["2"])};
}
.error-message {
color: ${bdTheme('mutedForeground')};
margin-bottom: ${unsafeCSS(spacing["4"])};
font-size: 0.875rem;
}
.error-actions {
display: flex;
gap: ${unsafeCSS(spacing["2"])};
justify-content: center;
}
.fallback-viewer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: ${bdTheme('background')};
}
.fallback-header {
padding: ${unsafeCSS(spacing["4"])};
border-bottom: 1px solid ${bdTheme('border')};
display: flex;
align-items: center;
justify-content: space-between;
background: ${bdTheme('card')};
}
.fallback-title {
font-weight: 500;
color: ${bdTheme('foreground')};
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing["2"])};
}
.fallback-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: ${unsafeCSS(spacing["8"])};
}
.fallback-message {
text-align: center;
color: ${bdTheme('mutedForeground')};
}
.fallback-icon {
font-size: 48px;
margin-bottom: ${unsafeCSS(spacing["4"])};
opacity: 0.5;
}
.fallback-text {
margin-bottom: ${unsafeCSS(spacing["4"])};
}
/* Scrollbar styling */
.pdf-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.pdf-container::-webkit-scrollbar-track {
background: ${bdTheme('muted')};
}
.pdf-container::-webkit-scrollbar-thumb {
background: ${bdTheme('border')};
border-radius: 4px;
}
.pdf-container::-webkit-scrollbar-thumb:hover {
background: ${bdTheme('mutedForeground')};
}
/* Responsive */
@media (max-width: 600px) {
.pdf-controls {
flex-wrap: wrap;
gap: ${unsafeCSS(spacing["2"])};
}
.pdf-controls-group {
flex-wrap: nowrap;
}
}
`,
];
public render(): TemplateResult {
if (this.hasError) {
return this.renderError();
}
return html`
<div class="container">
${this.isLoading ? html`
<div class="loading">
<sio-icon class="spinner" icon="loader" size="24"></sio-icon>
<div>Loading PDF...</div>
</div>
` : ''}
${this.pdfDocument ? html`
<div class="pdf-controls">
<div class="pdf-controls-group">
<sio-button
type="ghost"
size="sm"
@click=${this.previousPage}
?disabled=${this.currentPage <= 1}
>
<sio-icon icon="chevron-left" size="16"></sio-icon>
</sio-button>
<div class="page-info">
Page ${this.currentPage} of ${this.totalPages}
</div>
<sio-button
type="ghost"
size="sm"
@click=${this.nextPage}
?disabled=${this.currentPage >= this.totalPages}
>
<sio-icon icon="chevron-right" size="16"></sio-icon>
</sio-button>
</div>
<div class="pdf-controls-group">
<sio-button
type="ghost"
size="sm"
@click=${this.zoomOut}
>
<sio-icon icon="zoom-out" size="16"></sio-icon>
</sio-button>
<sio-button
type="ghost"
size="sm"
@click=${this.resetZoom}
>
<sio-icon icon="maximize-2" size="16"></sio-icon>
</sio-button>
<sio-button
type="ghost"
size="sm"
@click=${this.zoomIn}
>
<sio-icon icon="zoom-in" size="16"></sio-icon>
</sio-button>
</div>
<div class="pdf-controls-group">
<sio-button
type="ghost"
size="sm"
@click=${this.downloadPdf}
>
<sio-icon icon="download" size="16"></sio-icon>
</sio-button>
</div>
</div>
<div class="pdf-container">
<div class="pdf-canvas-wrapper">
<canvas></canvas>
</div>
</div>
` : ''}
</div>
`;
}
private renderError(): TemplateResult {
return html`
<div class="error-container">
<sio-icon class="error-icon" icon="alert-circle" size="48"></sio-icon>
<div class="error-title">Unable to display PDF</div>
<div class="error-message">
The PDF viewer couldn't load this document. This might be due to browser restrictions or an invalid PDF file.
</div>
<div class="error-actions">
<sio-button
type="primary"
size="sm"
@click=${this.downloadPdf}
>
<sio-icon icon="download" size="16"></sio-icon>
Download PDF
</sio-button>
<sio-button
type="outline"
size="sm"
@click=${this.openInNewTab}
>
<sio-icon icon="external-link" size="16"></sio-icon>
Open in New Tab
</sio-button>
</div>
</div>
`;
}
public async connectedCallback() {
await super.connectedCallback();
if (this.url) {
await this.loadPdf();
}
}
public async firstUpdated(_changedProperties: any) {
super.firstUpdated(_changedProperties);
// Set up resize observer for responsive rendering after first render
const container = this.shadowRoot?.querySelector('.pdf-container');
if (container) {
this.resizeObserver = new ResizeObserver(() => {
if (this.pdfDocument && !this.isLoading) {
this.renderPage();
}
});
this.resizeObserver.observe(container);
}
}
public async updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('url') && this.url) {
await this.loadPdf();
}
// Re-render when scale changes
if (changedProperties.has('scale') && this.pdfDocument && !this.isLoading) {
await this.renderPage();
}
}
private static async loadPdfJs(): Promise<void> {
if (SioPdfViewer.pdfJsLoaded) return;
if (SioPdfViewer.pdfJsLoading) {
return SioPdfViewer.pdfJsLoading;
}
SioPdfViewer.pdfJsLoading = new Promise(async (resolve, reject) => {
try {
// Load PDF.js from jsDelivr
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js';
script.onload = () => {
if (window.pdfjsLib) {
// Configure worker
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
SioPdfViewer.pdfJsLoaded = true;
resolve();
} else {
reject(new Error('PDF.js failed to load'));
}
};
script.onerror = () => reject(new Error('Failed to load PDF.js script'));
document.head.appendChild(script);
} catch (error) {
reject(error);
}
});
return SioPdfViewer.pdfJsLoading;
}
private async loadPdf() {
this.isLoading = true;
this.hasError = false;
this.pdfDocument = null;
try {
// Load PDF.js if not already loaded
await SioPdfViewer.loadPdfJs();
// Load the PDF document
const loadingTask = window.pdfjsLib.getDocument({
url: this.url,
// Enable range requests for better performance
disableRange: false,
// Enable streaming for large PDFs
disableStream: false,
});
this.pdfDocument = await loadingTask.promise;
this.totalPages = this.pdfDocument.numPages;
this.currentPage = 1;
this.isLoading = false;
// Render the first page
await this.renderPage();
} catch (error) {
console.error('Failed to load PDF:', error);
this.hasError = true;
this.isLoading = false;
}
}
private async renderPage() {
if (!this.pdfDocument) return;
// Cancel any ongoing render task
if (this.renderTask) {
this.renderTask.cancel();
}
try {
const page = await this.pdfDocument.getPage(this.currentPage);
const canvas = this.shadowRoot?.querySelector('canvas') as HTMLCanvasElement;
if (!canvas) return;
const context = canvas.getContext('2d');
const viewport = page.getViewport({ scale: this.scale });
// Set canvas dimensions
canvas.height = viewport.height;
canvas.width = viewport.width;
// Render PDF page into canvas context
const renderContext = {
canvasContext: context,
viewport: viewport
};
this.renderTask = page.render(renderContext);
await this.renderTask.promise;
} catch (error) {
if (error.name !== 'RenderingCancelledException') {
console.error('Error rendering page:', error);
}
}
}
private async previousPage() {
if (this.currentPage > 1) {
this.currentPage--;
await this.renderPage();
}
}
private async nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
await this.renderPage();
}
}
private async zoomIn() {
this.scale = Math.min(this.scale * 1.2, 3);
await this.renderPage();
}
private async zoomOut() {
this.scale = Math.max(this.scale / 1.2, 0.5);
await this.renderPage();
}
private async resetZoom() {
this.scale = 1;
await this.renderPage();
}
private downloadPdf() {
const a = document.createElement('a');
a.href = this.url;
a.download = this.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
private openInNewTab() {
window.open(this.url, '_blank');
}
public async disconnectedCallback() {
await super.disconnectedCallback();
// Clean up resize observer
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Cancel any ongoing render task
if (this.renderTask) {
this.renderTask.cancel();
}
// Destroy PDF document to free memory
if (this.pdfDocument) {
this.pdfDocument.destroy();
}
}
}

View File

@@ -47,7 +47,7 @@ export class SioRecorder extends DeesElement {
* Query for the div in our template that will be used for playback. * Query for the div in our template that will be used for playback.
*/ */
@query('#playback') @query('#playback')
private playbackDiv!: HTMLDivElement; private accessor playbackDiv!: HTMLDivElement;
static styles = css` static styles = css`
:host { :host {

View File

@@ -1,156 +0,0 @@
import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import * as sioInterfaces from '@social.io/interfaces';
@customElement('sio-subwidget-conversations')
export class SioSubwidgetConversations extends DeesElement {
// STATIC
// INSTANCE
public conversations: sioInterfaces.ISioConversation[] = [
{
subject: 'Pricing page',
parties: [
{
id: '1',
description: 'Lossless Support',
name: 'Lossless Support',
},
{
id: '2',
description: 'you',
name: 'you',
},
],
conversationBlocks: [
{
partyId: '1',
text: 'Hello there :) How can we help you?',
},
{
partyId: '2',
text: 'Hi! Where is your pricing page?',
},
],
},{
subject: 'Pricing page',
parties: [
{
id: '1',
description: 'Lossless Support',
name: 'Lossless Support',
},
{
id: '2',
description: 'you',
name: 'you',
},
],
conversationBlocks: [
{
partyId: '1',
text: 'Hello there :) How can we help you?',
},
{
partyId: '2',
text: 'Hi! Where is your pricing page?',
},
],
},
];
public static demo = () => html`<sio-subwidget-conversations></sio-subwidget-conversations>`;
public render(): TemplateResult {
return html`
${domtools.elementBasic.styles}
<style>
:host {
color: ${this.goBright ? '#666' : '#ccc'};
font-family: 'Dees Sans';
}
.conversationbox {
padding: 20px;
transition: all 0.1s;
min-height: 200px;
margin: 20px;
background: ${this.goBright ? '#fff' : '#111111'};
border-radius: 16px;
border-top: 1px solid rgba(250, 250, 250, 0.1);
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
margin-bottom: 20px;
}
.conversationbox .text {
font-family: 'Dees Sans';
margin-bottom: 8px;
}
.conversation {
display: block;
transition: all 0.1s;
padding: 8px 0px 8px 0px;
border-bottom: 1px solid;
border-image: radial-gradient(rgba(136, 136, 136, 0.44), rgba(136, 136, 136, 0)) 1 / 1 / 0 stretch;
cursor: pointer;
}
.conversation:last-of-type {
border-bottom: none;
margin-bottom: 8px;
}
.conversation:hover {
cursor: default;
}
.conversation:hover .gridcontainer {
transform: translateX(2px)
}
.conversation .gridcontainer {
display: grid;
grid-template-columns: 50px auto;
transition: transform 0.2s;
}
.conversation .gridcontainer .profilePicture {
height: 40px;
width: 40px;
border-radius: 50px;
background: ${this.goBright ? '#EEE' : '#222'};
}
.conversation .gridcontainer .text .topLine {
font-family: 'Dees Sans';
padding-top: 3px;
font-size: 12px;
}
.gridcontainer .gridcontainer .text .bottomLine {
font-family: 'Dees Sans';
font-size: 14px;
}
</style>
<div class="conversationbox">
<div class="text">Your conversations:</div>
${this.conversations.map((conversationArg) => {
return html`
<div class="conversation">
<div class="gridcontainer">
<div class="profilePicture"></div>
<div class="text">
<div class="topLine">Today at 8:01</div>
<div class="bottomLine">${conversationArg.subject}</div>
</div>
</div>
</div>
`;
})}
<dees-button>View more</dees-button>
</div>
`;
}
}

View File

@@ -1,64 +0,0 @@
import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import * as deesCatalog from '@design.estate/dees-catalog';
deesCatalog;
@customElement('sio-subwidget-onboardme')
export class SioSubwidgetOnboardme extends DeesElement {
@property()
public showCombox = false;
public static demo = () => html`
<sio-subwidget-onboardme></sio-subwidget-onboardme>
`;
constructor() {
super();
domtools.DomTools.setupDomTools();
}
public render(): TemplateResult {
return html`
${domtools.elementBasic.styles}
<style>
:host {
display: block;
position: relative;
transition: all 0.1s;
min-height: 200px;
margin: 20px 20px 40px 20px;
background: ${this.goBright ? '#fafafa' : '#111111'};
border-radius: 16px;
border-top: 1px solid rgba(250,250,250,0.1);
box-shadow: 0px 0px 5px rgba(0,0,0,0.3);
padding: 24px 24px 32px 24px;
color: #CCC;
overflow: hidden;
}
:host(:hover) {
}
.brandingbox {
text-align: center;
position: absolute;
width: 100%;
bottom: 0px;
left: 0px;
font-size: 10px;
padding: 3px;
border-top: 1px solid rgba(250,250,250, 0.1);
font-family: 'Dees Code';
background: ${this.goBright ? '#eee' : '#111111'};
color: #666;
}
</style>
Or search through our documentation
<dees-input-text key="searchTerm" label="Search Term:"></dees-input-text>
<dees-button>Search</dees-button>
<div class="brandingbox">last updated: ${new Date().toISOString()}</div>
`;
}
}

View File

@@ -1,3 +1,45 @@
import { html } from '@design.estate/dees-element'; import { html } from '@design.estate/dees-element';
export const mainpage = () => html` <lele-statusbar></lele-statusbar> `; export const mainpage = () => html`
<style>
body {
margin: 0;
padding: 0;
background: #f5f5f5;
}
.demo-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.demo-section {
background: white;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 1200px;
width: 100%;
}
h2 {
margin-bottom: 20px;
font-family: sans-serif;
}
.component-demo {
margin: 20px 0;
position: relative;
min-height: 700px;
}
</style>
<div class="demo-container">
<div class="demo-section">
<h2>Social.io Catalog Components</h2>
<div class="component-demo">
<h3>FAB with Combox Demo</h3>
<sio-fab .showCombox=${true}></sio-fab>
</div>
</div>
</div>
`;

View File

@@ -1,14 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
}, },
"exclude": [ "exclude": [
"dist_*/**/*.d.ts" "dist_*/**/*.d.ts"