Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slide-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@open-slide/core': minor
---

Add free-form slide tags with a list filter and a per-slide tag editor.
197 changes: 197 additions & 0 deletions packages/core/src/app/components/tag-combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { Plus, X } from 'lucide-react';
import { useId, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';

/**
* Normalise a free-typed tag to match the server's `sanitizeTag`: trim,
* lowercase, collapse whitespace to dashes, strip anything outside
* letters/numbers/._-, and cap at 64 chars — so a created chip never changes or
* vanishes after the server round-trip.
*/
export function normalizeTag(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}._-]/gu, '')
.slice(0, 64);
}

/**
* Token-input combobox for tags: selected tags render as chips inside the box,
* the user types to filter `suggestions`, and clicks / Enter to select. With
* `allowCreate`, a typed value that matches no suggestion can be created.
*
* The suggestions list is a plain positioned element (not a Popover) so focus
* stays in the text input while navigating with the keyboard.
*
* Presentational and locale-agnostic — call sites pass localized `placeholder`,
* `ariaLabel` and `createLabel`.
*/
export function TagCombobox({
value,
onChange,
suggestions,
allowCreate = false,
placeholder,
ariaLabel,
createLabel,
className,
}: {
value: string[];
onChange: (next: string[]) => void;
suggestions: string[];
allowCreate?: boolean;
placeholder?: string;
ariaLabel?: string;
createLabel?: (raw: string) => string;
className?: string;
}) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const listId = useId();

const q = query.trim().toLowerCase();
const filtered = useMemo(
() =>
suggestions
.filter((s) => !value.includes(s))
.filter((s) => (q ? s.toLowerCase().includes(q) : true)),
[suggestions, value, q],
);
const normalized = normalizeTag(query);
const showCreate =
allowCreate &&
normalized.length > 0 &&
!value.includes(normalized) &&
!suggestions.some((s) => s.toLowerCase() === normalized);
const optionCount = filtered.length + (showCreate ? 1 : 0);

const add = (tag: string) => {
if (!tag || value.includes(tag)) return;
onChange([...value, tag]);
setQuery('');
setActiveIndex(0);
};
const removeTag = (tag: string) => onChange(value.filter((t) => t !== tag));
const commitActive = () => {
if (activeIndex < filtered.length) add(filtered[activeIndex]);
else if (showCreate) add(normalized);
};

return (
<div className={cn('relative', className)}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: clicking empty box area focuses the input */}
<div
className="flex min-h-8 w-full flex-wrap items-center gap-1 rounded-[6px] border border-border bg-background px-1.5 py-1 focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/30"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
e.preventDefault();
inputRef.current?.focus();
}
}}
>
{value.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-[4px] border border-border bg-muted/60 py-0.5 pl-1.5 pr-1 text-[11.5px] text-foreground"
>
{tag}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => removeTag(tag)}
className="flex size-3.5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
aria-label={tag}
>
<X className="size-3" aria-hidden />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-label={ariaLabel}
value={query}
placeholder={value.length === 0 ? placeholder : undefined}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIndex(0);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setActiveIndex((i) => Math.min(i + 1, Math.max(0, optionCount - 1)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(0, i - 1));
} else if (e.key === 'Enter') {
if (open && optionCount > 0) {
e.preventDefault();
commitActive();
}
} else if (e.key === 'Escape') {
setOpen(false);
} else if (e.key === 'Backspace' && query === '' && value.length > 0) {
removeTag(value[value.length - 1]);
}
}}
className="h-6 min-w-[90px] flex-1 bg-transparent px-1 text-[12.5px] outline-none placeholder:text-muted-foreground/70"
/>
</div>
{open && optionCount > 0 && (
<ul
id={listId}
className="absolute z-50 mt-1 max-h-[260px] w-full min-w-[180px] overflow-y-auto rounded-[6px] border border-border bg-popover p-1 shadow-floating"
>
{filtered.map((s, i) => (
<li key={s}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => add(s)}
className={cn(
'flex w-full items-center rounded-[4px] px-2 py-1.5 text-left text-[12.5px]',
activeIndex === i ? 'bg-muted text-foreground' : 'hover:bg-muted/60',
)}
>
<span className="truncate">{s}</span>
</button>
</li>
))}
{showCreate && (
<li>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActiveIndex(filtered.length)}
onClick={() => add(normalized)}
className={cn(
'flex w-full items-center gap-2 rounded-[4px] px-2 py-1.5 text-left text-[12.5px]',
activeIndex === filtered.length
? 'bg-muted text-foreground'
: 'hover:bg-muted/60',
)}
>
<Plus className="size-3 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate">
{createLabel ? createLabel(normalized) : normalized}
</span>
</button>
</li>
)}
</ul>
)}
</div>
);
}
19 changes: 19 additions & 0 deletions packages/core/src/app/lib/folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ async function patchSlideName(slideId: string, name: string): Promise<void> {
if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`);
}

async function patchSlideTags(slideId: string, tags: string[]): Promise<void> {
const res = await fetch(`/__slides/${slideId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tags }),
});
if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`);
}

async function duplicateSlideReq(slideId: string, newId?: string): Promise<string> {
const init: RequestInit = { method: 'POST' };
if (newId !== undefined) {
Expand Down Expand Up @@ -106,6 +115,7 @@ export type UseFoldersResult = {
reorder: (ids: string[]) => Promise<void>;
assign: (slideId: string, folderId: string | null) => Promise<void>;
renameSlide: (slideId: string, name: string) => Promise<void>;
setSlideTags: (slideId: string, tags: string[]) => Promise<void>;
duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
deleteSlide: (slideId: string) => Promise<void>;
refresh: () => Promise<void>;
Expand Down Expand Up @@ -206,6 +216,14 @@ export function useFolders(): UseFoldersResult {
[refresh],
);

const setSlideTags = useCallback(
async (slideId: string, tags: string[]) => {
await patchSlideTags(slideId, tags);
await refresh();
},
[refresh],
);

const duplicateSlide = useCallback(
async (slideId: string, newId?: string) => {
const duplicatedId = await duplicateSlideReq(slideId, newId);
Expand All @@ -232,6 +250,7 @@ export function useFolders(): UseFoldersResult {
reorder,
assign,
renameSlide,
setSlideTags,
duplicateSlide,
deleteSlide,
refresh,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/app/lib/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type SlideMeta = {
theme?: string;
/** ISO 8601 timestamp. Set once at scaffold time; used to sort the slide list. */
createdAt?: string;
/** Free-form tags for filtering the slide list — language, topic, etc. */
tags?: string[];
};

export type SlideModule = {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/app/lib/slides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import {
slideCreatedAt as createdAt,
slideIds as ids,
loadSlide as load,
slideTags as tags,
slideThemes as themes,
} from 'virtual:open-slide/slides';
import type { SlideModule } from './sdk';

export const slideIds: string[] = ids;
export const slideThemes: Record<string, string> = themes;
export const slideCreatedAt: Record<string, number> = createdAt;
export const slideTags: Record<string, string[]> = tags;

export function slidesByTheme(themeId: string): string[] {
return slideIds.filter((id) => slideThemes[id] === themeId);
Expand Down
Loading