Files
cosscom__coss/apps/ui/app/particles/particles-display.tsx
devin-ai-integration[bot] 682caddcb2 feat(ui): add 18 new particles + weighted sorting for particles page (#633)
* feat(ui): add 19 new input/group/field particles

- p-input-13: Input with label and required indicator
- p-input-14: Input with optional label
- p-input-15: Input with custom border and background
- p-field-19: Field with error state
- p-group-16: Group with copy button and input
- p-group-17: Group with input and currency text
- p-group-18: Group with select and input
- p-group-19: Group with input and select
- p-group-20: Group with input and text button
- p-group-21: Group with separate input and button
- p-group-22: Group with two inputs for range
- p-input-group-20: Input group with search icon
- p-input-group-21: Input group with start tooltip
- p-input-group-22: Input group with clear button
- p-input-group-23: Search input group with loader and voice button
- p-input-group-24: Input group with character counter
- p-input-group-25: Input with characters remaining counter
- p-input-group-26: Password input with strength indicator
- p-button-40: Read-only disabled button

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): use match prop instead of forceShow on FieldError

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): address feedback on particles

- p-group-16: replace copy button with add button (real use-case)
- p-field-19: use match={true} to force show FieldError
- p-group-18, p-group-19: add min-w-none to reduce select trigger width
- p-input-7: refactor to use Group with div wrapper around button
- p-input-14: add w-full and inline-flex to wrapper div
- p-input-15: add before:hidden to hide inset shadow
- p-input-group-24, p-input-group-25: set chars limit to 14
- p-input-16: new particle with loader on the right (variant of p-input-12)
- Remove p-group-21 (duplicate of refactored p-input-7)

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): address additional feedback on particles

- p-group-17: use GroupText with Label render prop
- p-input-group-25: use Field and FieldDescription instead of manual markup

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): p-field-19 use typeMismatch for proper error behavior

- Use match='typeMismatch' instead of match={true} so error clears when valid email is entered
- Change defaultValue to 'invalid-email' (no @) to trigger typeMismatch initially
- Remove aria-invalid since Field handles this automatically

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): remove div wrapper around button in p-group-16

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): p-group-22 use NumberField instead of Input

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): p-field-19 show error on page load and clear on valid input

- Use controlled state with invalid prop to show error initially
- Update isInvalid state on change based on input validity
- Use validationMode='onChange' for immediate validation feedback

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): p-input-14 use Label with render prop for Optional text

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): remove p-field-19 particle

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): remove labels from p-input-group-24 and p-input-group-25

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(ui): reorganize registry-particles to show basic inputs first

Move autocomplete and combobox sections to come after p-input-* and
p-input-group-* sections. This ensures that when filtering by 'input'
tag, basic input components appear first, followed by input groups,
and then more complex patterns like autocomplete and combobox.

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(ui): move field/group/form particles with input tag after p-input-group-*

Move specific particles that have 'input' as a secondary tag to come
after the p-input-* and p-input-group-* sections:
- p-field-6, p-field-7, p-field-8, p-field-9, p-field-17
- p-form-1
- p-group-2, p-group-16, p-group-17, p-group-18, p-group-19, p-group-20

This ensures that when filtering by 'input' tag, basic input components
appear first, followed by input groups, then field/group compositions,
and finally autocomplete/combobox patterns.

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(ui): reorder registry - field/form particles after combobox

Move field and form particles with 'input' tag to come after combobox
section. New order for input-tagged particles:
1. p-input-*
2. p-input-group-*
3. p-group-*
4. p-autocomplete-*
5. p-combobox-*
6. p-field-* (with input tag)
7. p-form-* (at the bottom)

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* revert: restore registry to state before reorganization

Reverts the registry reorganization commits to restore the original
ordering. The reorganization approach needs to be reconsidered.

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* chore: rebuild registry after restore

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* feat(ui): add weighted relevance sorting for particles page

Implement smart sorting for particles based on relevance to search terms:
- Name prefix match (+30): p-input-* ranks highest for 'input' search
- registryDependency match (+20): components using @coss/input get boosted
- Primary category match (+10): first category match gets bonus

This ensures basic inputs appear first when searching 'input', followed
by input groups, then autocomplete/combobox patterns.

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* refactor(ui): unify categories and use single tag icon

- Merge componentCategories and tagCategories into single registryCategories array
- Remove isComponentCategory and isTagCategory helper functions
- Use Label icon for all categories in search UI (no visual distinction)
- Simplify types by removing isComponent property

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): exclude field/fieldset from sorting weight + add input to number fields

- Exclude 'field' and 'fieldset' from primary category weight bonus to avoid
  visually separating similar components when searching
- Add 'input' category to all number field particles (p-number-field-1 to 10)
  so they appear when filtering by 'input'

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* chore: rebuild registry with updated number field categories

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* fix(ui): replace p-button-40 with p-input-17 read-only input

User wanted a read-only input, not a disabled button. Created p-input-17
with readOnly prop and read-only:bg-muted styling.

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* chore: update registry.json with p-input-17

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* revert: remove field/fieldset exclusion from weight calculation

Reverts the EXCLUDED_PRIMARY_CATEGORIES logic that was preventing field
and fieldset from contributing to the primary category weight bonus.

Co-Authored-By: pasquale@cal.com <pasquale@cal.com>

* mc: remove label from p-input-17

* improve group component to allow number field

* mc

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: pasquale@cal.com <pasquale@cal.com>
Co-authored-by: pasqualevitiello <pasqualevitiello@gmail.com>
2026-01-14 14:36:28 +01:00

122 lines
3.2 KiB
TypeScript

import { cache, Suspense } from "react";
import { Index } from "@/registry/__index__";
import { Skeleton } from "@/registry/default/ui/skeleton";
import type { RegistryCategory } from "@/registry/registry-categories";
import { ParticleCard } from "./particle-card";
import { ParticleCardContainer } from "./particle-card-container";
type Particle = {
name: string;
categories?: RegistryCategory[];
registryDependencies?: string[];
meta?: { className?: string; colSpan?: number };
};
function calculateRelevanceWeight(
particle: Particle,
searchTerms: RegistryCategory[],
): number {
let weight = 0;
for (const term of searchTerms) {
const normalizedTerm = term.replace(/\s+/g, "-");
if (particle.name.startsWith(`p-${normalizedTerm}-`)) {
weight += 30;
}
const deps = particle.registryDependencies ?? [];
if (deps.some((dep) => dep === `@coss/${normalizedTerm}`)) {
weight += 20;
}
const categories = particle.categories ?? [];
if (categories[0] === term) {
weight += 10;
}
}
return weight;
}
function ParticleCardSkeleton({ className }: { className?: string }) {
return (
<ParticleCardContainer
className={className}
footer={
<>
<div className="flex flex-1 gap-1">
<Skeleton className="size-4 shrink-0" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex items-center gap-1.5">
<Skeleton className="size-7" />
<Skeleton className="h-7 w-20" />
</div>
</>
}
>
<Skeleton className="h-7 w-64" />
</ParticleCardContainer>
);
}
// Cache the particles array to avoid recomputing on every render
const getParticles = cache(() => {
return Object.values(Index).filter(
(item) => item.type === "registry:block",
) as Particle[];
});
export async function ParticlesDisplay({
selectedCategories,
}: {
selectedCategories: RegistryCategory[];
}) {
const particles = getParticles();
const filteredParticles = particles
.filter((particle) => {
const categories = particle.categories ?? [];
return selectedCategories.every((value) => categories.includes(value));
})
.sort((a, b) => {
const weightA = calculateRelevanceWeight(a, selectedCategories);
const weightB = calculateRelevanceWeight(b, selectedCategories);
return weightB - weightA;
});
if (filteredParticles.length === 0) {
return (
<div className="text-center">
<p className="text-muted-foreground">
No particles found for the selected filters
</p>
</div>
);
}
return (
<div className="grid flex-1 items-stretch gap-9 pb-12 lg:grid-cols-2 lg:gap-6 xl:gap-9">
{filteredParticles.map((particle) => {
const className = particle.meta?.className as string | undefined;
const colSpan = particle.meta?.colSpan as number | undefined;
return (
<Suspense
fallback={<ParticleCardSkeleton className={className} />}
key={particle.name}
>
<ParticleCard
className={className}
colSpan={colSpan}
name={particle.name}
/>
</Suspense>
);
})}
</div>
);
}